mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 14:08:23 -06:00
Merge main into feat/align-selected-lines
Main took integration batch 6 (#4566). Conflict in edit_text_ui.rs: the overlay keeps this PR's view_id with main's interact_pos comment. The two TextDraft literals from #4441's tests take selection and typing_from.
This commit is contained in:
+33
-33
@@ -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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -285,17 +285,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
|
||||
## Compiled in through dependencies (5)
|
||||
|
||||
|
||||
+33
-33
@@ -2,7 +2,7 @@
|
||||
path = "crates/ui-egui/src/i18n/el.tsv"
|
||||
title = "Greek interface translation catalog"
|
||||
author = "Camthalion77 and contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -26,7 +26,7 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
|
||||
title = "Simplified Chinese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -36,7 +36,7 @@ usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/m
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
title = "Traditional Chinese (Taiwan) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -46,7 +46,7 @@ usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
title = "Czech interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -56,7 +56,7 @@ usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
title = "Brazilian Portuguese interface translation catalog"
|
||||
author = "André Cardoso (andrecards), PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -66,7 +66,7 @@ usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
title = "Spanish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -76,7 +76,7 @@ usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
title = "French interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -86,7 +86,7 @@ usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
title = "German interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -96,7 +96,7 @@ usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/lo.tsv"
|
||||
title = "Lao interface translation catalog"
|
||||
author = "@bounkirdni-2025"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -106,7 +106,7 @@ usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
title = "Telugu interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -116,7 +116,7 @@ usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
title = "Italian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -126,7 +126,7 @@ usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
title = "Polish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -136,7 +136,7 @@ usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -146,7 +146,7 @@ usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -156,7 +156,7 @@ usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
title = "Bulgarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -166,7 +166,7 @@ usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
title = "Kazakh interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -176,7 +176,7 @@ usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -186,7 +186,7 @@ usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -196,7 +196,7 @@ usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
title = "Arabic interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -206,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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -226,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -236,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -245,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -254,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -264,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -275,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3396,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3406,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3428,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3482,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3492,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3502,7 +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
|
||||
@@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
|
||||
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
|
||||
@@ -225,6 +225,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
|
||||
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
|
||||
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged.
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
|
||||
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
|
||||
@@ -318,6 +319,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<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. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
|
||||
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
|
||||
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
|
||||
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
|
||||
+83
-34
@@ -14,6 +14,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
pub mod appearance;
|
||||
@@ -774,8 +776,9 @@ thread_local! {
|
||||
static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect.
|
||||
fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), AnnotError> {
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty
|
||||
/// list removes a direct `/Annots`.
|
||||
pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), pdfcraft_cos::CosError> {
|
||||
let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots"));
|
||||
match existing {
|
||||
Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)),
|
||||
@@ -1665,36 +1668,52 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul
|
||||
if let Some(r) = target.as_ref() {
|
||||
unlocked(doc, r)?;
|
||||
}
|
||||
let mut doomed: Vec<ObjRef> = target.as_ref().into_iter().collect();
|
||||
// Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint.
|
||||
loop {
|
||||
let before = doomed.len();
|
||||
for e in &list {
|
||||
let Some(r) = e.as_ref() else { continue };
|
||||
if doomed.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t));
|
||||
if points_at(b"IRT") || points_at(b"Parent") {
|
||||
doomed.push(r);
|
||||
}
|
||||
}
|
||||
for r in doomed.clone() {
|
||||
if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))
|
||||
&& !doomed.contains(&pop)
|
||||
{
|
||||
doomed.push(pop);
|
||||
}
|
||||
}
|
||||
if doomed.len() == before {
|
||||
break;
|
||||
// An inline comment has no reference to collect; the pop-up it names still goes.
|
||||
let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
|
||||
let doomed = with_replies_and_popups(doc, &list, seed);
|
||||
let kept: Vec<Object> = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect();
|
||||
Ok(set_annots(doc, p, kept)?)
|
||||
}
|
||||
|
||||
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
|
||||
/// one of them, or it is written inline (a file can, though the standard asks for references) and
|
||||
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
|
||||
/// for the walk to collect, so it is matched here.
|
||||
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
|
||||
match entry.as_ref() {
|
||||
Some(r) => going.contains(&r),
|
||||
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
|
||||
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
|
||||
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
|
||||
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
|
||||
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
|
||||
/// matches inline entries.
|
||||
pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator<Item = ObjRef>) -> HashSet<ObjRef> {
|
||||
let mut hanging: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
|
||||
for e in list {
|
||||
let obj = doc.resolve(e);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
|
||||
// pop-up it names.
|
||||
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
|
||||
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
|
||||
hanging.entry(on).or_default().push(goes);
|
||||
}
|
||||
}
|
||||
let kept: Vec<Object> =
|
||||
list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect();
|
||||
set_annots(doc, p, kept)
|
||||
let mut going = HashSet::new();
|
||||
let mut todo: Vec<ObjRef> = doomed.into_iter().collect();
|
||||
while let Some(r) = todo.pop() {
|
||||
if !going.insert(r) {
|
||||
continue;
|
||||
}
|
||||
todo.extend(hanging.get(&r).into_iter().flatten());
|
||||
todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")));
|
||||
}
|
||||
going
|
||||
}
|
||||
|
||||
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
|
||||
@@ -2272,6 +2291,10 @@ pub struct Summary {
|
||||
pub modified: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
|
||||
pub grouped: bool,
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub state: Option<String>,
|
||||
@@ -2305,13 +2328,35 @@ pub fn is_comment_subtype(subtype: &str) -> bool {
|
||||
!matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark")
|
||||
}
|
||||
|
||||
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
|
||||
/// follows it, within a few steps (a cycle stops there).
|
||||
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
for _ in 0..8 {
|
||||
match &*doc.get(r) {
|
||||
Object::Ref(next) => r = *next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
|
||||
pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(pages) = page_refs(doc) else { return out };
|
||||
for (page, p) in pages.iter().enumerate() {
|
||||
for (index, entry) in annots(doc, *p).iter().enumerate() {
|
||||
let obj = doc.resolve(entry);
|
||||
let lists: Vec<Vec<Object>> = pages.iter().map(|p| annots(doc, *p)).collect();
|
||||
// Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference.
|
||||
let mut slots: std::collections::HashMap<ObjRef, (usize, usize)> = std::collections::HashMap::new();
|
||||
for (page, list) in lists.iter().enumerate() {
|
||||
for (index, entry) in list.iter().enumerate() {
|
||||
if let Some(r) = entry.as_ref() {
|
||||
slots.entry(final_ref(doc, r)).or_insert((page, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (page, list) in lists.iter().enumerate() {
|
||||
for (index, entry) in list.iter().enumerate() {
|
||||
let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r)));
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue };
|
||||
if !is_comment_subtype(&subtype) {
|
||||
@@ -2345,7 +2390,9 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
|
||||
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
|
||||
out.push(Summary {
|
||||
page,
|
||||
index,
|
||||
@@ -2357,6 +2404,8 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
modified: text_value(doc, d, b"M"),
|
||||
name: text_value(doc, d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: d.name(b"RT") == Some(b"Group"),
|
||||
rect,
|
||||
color,
|
||||
state: text_value(doc, d, b"State"),
|
||||
|
||||
@@ -320,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot
|
||||
let (p, _) = link_at(doc, page, index)?;
|
||||
let mut list = annots(doc, p);
|
||||
list.remove(index);
|
||||
set_annots(doc, p, list)
|
||||
Ok(set_annots(doc, p, list)?)
|
||||
}
|
||||
|
||||
/// Remove every link on `pages` (all when `None`). Returns how many went.
|
||||
|
||||
@@ -229,6 +229,47 @@ fn delete_takes_popup_and_reply_chain() {
|
||||
assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 }));
|
||||
}
|
||||
|
||||
/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for
|
||||
/// references) goes with the comment it points at, and so does a pop-up an inline reply or an
|
||||
/// inline comment names. An unrelated inline comment stays until it is deleted itself.
|
||||
#[test]
|
||||
fn delete_takes_inline_replies_and_popups() {
|
||||
let mut doc = fixture();
|
||||
let mut popup = |note: &str| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
d.set(b"Contents".to_vec(), PdfString::text(note));
|
||||
doc.add(Object::Dict(d))
|
||||
};
|
||||
let (reply_popup, square_popup) = (popup("of the reply"), popup("of the square"));
|
||||
let inline = |subtype: &str, links: &[(&[u8], ObjRef)]| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
links.iter().for_each(|(key, r)| d.set(key.to_vec(), Object::Ref(*r)));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let stamp = ObjRef::new(6, 0);
|
||||
let entries = vec![
|
||||
Object::Ref(stamp),
|
||||
inline("Text", &[(b"IRT", stamp), (b"Popup", reply_popup)]),
|
||||
Object::Ref(reply_popup),
|
||||
inline("Popup", &[(b"Parent", stamp)]),
|
||||
inline("Square", &[(b"Popup", square_popup)]),
|
||||
Object::Ref(square_popup),
|
||||
];
|
||||
doc.set(ObjRef::new(5, 0), Object::Array(entries));
|
||||
let subtypes = |doc: &Document| -> Vec<String> {
|
||||
list(doc, 1).iter().map(|d| String::from_utf8_lossy(d.name(b"Subtype").unwrap()).into_owned() + ":" + &text(d, b"Contents")).collect()
|
||||
};
|
||||
delete_annotation(&mut doc, 1, 0).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(subtypes(&doc), ["Square:", "Popup:of the square"]);
|
||||
delete_annotation(&mut doc, 1, 0).unwrap();
|
||||
assert!(subtypes(&reopen(&doc)).is_empty(), "the inline comment's pop-up went with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_last_comment_removes_annots() {
|
||||
let mut doc = fixture();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! Geometry follows the automation convention: points from the top-left of the displayed page,
|
||||
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
@@ -152,8 +154,13 @@ impl Automation {
|
||||
fn comment_target(&self, a: &Args) -> Result<(usize, usize)> {
|
||||
let all = self.comments(a)?;
|
||||
if let Some(id) = a.opt_str("id")? {
|
||||
let c =
|
||||
all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?;
|
||||
// `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`):
|
||||
// the first match could be another page's comment, so a shared id is refused.
|
||||
let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id));
|
||||
let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?;
|
||||
if named.next().is_some() {
|
||||
return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)")));
|
||||
}
|
||||
return Ok((c.page, c.index));
|
||||
}
|
||||
let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else {
|
||||
@@ -193,33 +200,44 @@ impl Automation {
|
||||
}
|
||||
v
|
||||
};
|
||||
// Every reply under the comment its thread starts from, at any depth, in document order:
|
||||
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
|
||||
// drop it. Parents are found by reference, as `/NM` need not be unique. A status or
|
||||
// checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the
|
||||
// Comments panel: one set on a reply is not the comment's.
|
||||
let tops = pdfcraft_render::thread_tops(&all);
|
||||
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
for r in &all {
|
||||
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
|
||||
let key = match r.parent {
|
||||
Some(parent) if r.state.is_some() => parent,
|
||||
_ => top,
|
||||
};
|
||||
threads.entry(key).or_default().push(r);
|
||||
}
|
||||
// An annotation's review status and checkmark: the latest of each set on it.
|
||||
let states = |c: &Annotation, v: &mut Value| {
|
||||
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
|
||||
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
|
||||
};
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
.filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p))
|
||||
.filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p))
|
||||
.map(|c| {
|
||||
let mut v = view(c);
|
||||
// Every reply under this comment, at any depth, in document order. A reply to a
|
||||
// reply keeps its `/IRT` on the direct parent, so following one level would drop it.
|
||||
let descends_from = |r: &Annotation, root: &str| -> bool {
|
||||
let mut parent = r.in_reply_to.as_deref();
|
||||
for _ in 0..64 {
|
||||
let Some(nm) = parent else { return false };
|
||||
if nm == root {
|
||||
return true;
|
||||
}
|
||||
parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref());
|
||||
}
|
||||
false
|
||||
};
|
||||
let thread: Vec<&Annotation> = match c.name.as_deref() {
|
||||
Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone());
|
||||
v["status"] = json!(status);
|
||||
v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
|
||||
states(c, &mut v);
|
||||
v["locked"] = json!(c.locked);
|
||||
v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect();
|
||||
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
v["replies"] = thread
|
||||
.iter()
|
||||
.filter(|r| r.state.is_none())
|
||||
.map(|r| {
|
||||
let mut reply = view(r);
|
||||
states(r, &mut reply);
|
||||
reply
|
||||
})
|
||||
.collect();
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -60,7 +60,10 @@ fn color() -> Value {
|
||||
fn comment_ref(mut extra: Value) -> Value {
|
||||
if let Some(props) = extra.as_object_mut() {
|
||||
props.insert("doc".into(), doc());
|
||||
props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." }));
|
||||
props.insert(
|
||||
"id".into(),
|
||||
json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }),
|
||||
);
|
||||
props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." }));
|
||||
props.insert(
|
||||
"index".into(),
|
||||
@@ -702,7 +705,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
|
||||
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
|
||||
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.")
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
|
||||
t(
|
||||
|
||||
@@ -2121,6 +2121,83 @@ fn nested_replies_stay_in_the_listing() {
|
||||
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_ids_repeat_across_pages() {
|
||||
let dir = workdir("repeated-ids");
|
||||
// Contributor-original PDF, shaped like PyMuPDF output.
|
||||
std::fs::write(
|
||||
dir.join("fitz.pdf"),
|
||||
b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap();
|
||||
let replies = |a: &mut Automation| -> Vec<(String, Vec<String>)> {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc }));
|
||||
list["comments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut texts: Vec<String> =
|
||||
c["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str().map(String::from)).collect();
|
||||
texts.sort();
|
||||
(c["contents"].as_str().unwrap().to_string(), texts)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string()])]);
|
||||
let refused = err(&mut a, "comment_reply", json!({ "doc": doc, "id": "fitz-A0", "text": "LATER" }));
|
||||
assert!(refused.to_string().contains("several comments"), "{refused}");
|
||||
ok(&mut a, "comment_reply", json!({ "doc": doc, "page": 2, "index": 1, "text": "LATER" }));
|
||||
assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]);
|
||||
}
|
||||
|
||||
/// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not
|
||||
/// its comment's, as the Comments panel shows it, and is reported on the reply. One whose
|
||||
/// annotation can't be found is not a comment of its own.
|
||||
#[test]
|
||||
fn a_checkmark_on_a_reply_is_not_its_comments() {
|
||||
let dir = workdir("reply-checkmark");
|
||||
// Contributor-original PDF: a comment, a reply, a checkmark and a status on the reply, and a
|
||||
// status whose annotation (object 9) doesn't exist.
|
||||
std::fs::write(
|
||||
dir.join("checked.pdf"),
|
||||
b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [200 20 220 40] /F 30 /IRT 9 0 R /T (Gamma) /StateModel /Review /State /Rejected >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "checked.pdf" }))["doc"].as_u64().unwrap();
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 1, "{list}");
|
||||
let c = &list["comments"][0];
|
||||
assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}");
|
||||
assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}");
|
||||
let reply = &c["replies"][0];
|
||||
assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protecting_through_tools() {
|
||||
let dir = workdir("protect");
|
||||
|
||||
@@ -1317,6 +1317,17 @@ impl Document {
|
||||
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
|
||||
}
|
||||
|
||||
/// Free what was added since the file was read and nothing refers to any more: the objects
|
||||
/// [`SaveOptions::drop_unlinked`](crate::SaveOptions::drop_unlinked) leaves out of an
|
||||
/// incremental save, freed as it writes them (the generation stays). For a writer about to
|
||||
/// add an object that nothing refers to either, which that option would leave out too.
|
||||
pub fn free_unlinked_additions(&mut self) {
|
||||
for num in crate::writer::unlinked_additions(self) {
|
||||
let generation = self.generation(num);
|
||||
self.overlay.insert(num, Slot::Freed(generation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `current`'s object graph as unsaved edits of this document.
|
||||
///
|
||||
/// Used when the working snapshot was restored from an earlier revision and the next
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
//!
|
||||
//! * `write_incremental` appends the edited objects, a cross-reference section in the same style
|
||||
//! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid.
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid. With
|
||||
//! `SaveOptions::drop_unlinked`, objects added since the file was read that nothing refers to
|
||||
//! any more are left out (written as free).
|
||||
//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer
|
||||
//! entries are copied, including objects reachable only from them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::CosError;
|
||||
use crate::document::{Document, ObjectReader};
|
||||
use crate::document::{Document, ObjectReader, XrefEntry};
|
||||
use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
/// Serialize one object. Deterministic: same object → same bytes.
|
||||
@@ -156,11 +159,18 @@ pub struct SaveOptions {
|
||||
/// Full saves: pack non-stream objects into compressed object streams with a cross-reference
|
||||
/// stream (PDF 1.5+, §7.5.7–7.5.8). Off writes a classic table that any reader accepts.
|
||||
pub object_streams: bool,
|
||||
/// Incremental saves: leave out what was added since the file was read and nothing refers to
|
||||
/// any more, such as a comment added and deleted again or a redaction mark added and cleared
|
||||
/// (written as free). Off by default, because a writer may add an object only it finds again
|
||||
/// by scanning, as a standalone document timestamp is; such a writer frees the leftovers
|
||||
/// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave
|
||||
/// these out.
|
||||
pub drop_unlinked: bool,
|
||||
}
|
||||
|
||||
impl Default for SaveOptions {
|
||||
fn default() -> Self {
|
||||
Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true }
|
||||
Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +219,14 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64;
|
||||
let mut out = Vec::new();
|
||||
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
|
||||
let unlinked = if opts.drop_unlinked { unlinked_additions(&doc) } else { HashSet::new() };
|
||||
for (num, generation, obj) in doc.overlay_entries() {
|
||||
match obj {
|
||||
// Written as free, so `/Size` still covers every number this document handed out. The
|
||||
// generation stays: the number was never in use, and an undo can bring it back as is.
|
||||
Some(_) if unlinked.contains(&num) => {
|
||||
offsets.insert(num, (0, generation, false));
|
||||
}
|
||||
Some(o) => {
|
||||
offsets.insert(num, (base + out.len() as u64, generation, true));
|
||||
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
|
||||
@@ -387,7 +403,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
/// Object `r` read again for writing. The traversal read it once and only kept its number, so
|
||||
/// a second read that fails, finds nothing or finds a different kind of object must fail the
|
||||
/// save rather than write `null` in its place.
|
||||
fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result<std::sync::Arc<Object>, CosError> {
|
||||
fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result<Arc<Object>, CosError> {
|
||||
let o = reader.try_get(r)?;
|
||||
if matches!(*o, Object::Null) || matches!(*o, Object::Stream(_)) != is_stream {
|
||||
return Err(CosError::Syntax { offset: 0, detail: format!("object {} read differently while saving; nothing was written", r.num) });
|
||||
@@ -439,6 +455,37 @@ fn output_number(index: usize) -> Result<u32, CosError> {
|
||||
u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() })
|
||||
}
|
||||
|
||||
/// Objects added since the file was read (their numbers are not in use in it) that nothing an
|
||||
/// incremental update writes refers to: what one edit added and a later one unlinked, such as a
|
||||
/// deleted comment or a cleared redaction mark. The file never held them, so leaving them out
|
||||
/// loses nothing, while writing them would keep what the user removed. Only the update is walked:
|
||||
/// from the trailer and the file's changed objects through additions. An unchanged object of the
|
||||
/// file is not followed: a reference it holds to such a number already reads as null in the file
|
||||
/// (a damaged file's dangling reference), and it still does.
|
||||
pub(crate) fn unlinked_additions(doc: &Document) -> HashSet<u32> {
|
||||
let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. }));
|
||||
let mut pending: HashMap<u32, Arc<Object>> =
|
||||
doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect();
|
||||
if pending.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
let mut stack: Vec<ObjRef> = Vec::new();
|
||||
doc.trailer().iter().for_each(|(_, v)| collect_refs(v, &mut |r| stack.push(r)));
|
||||
for (num, _, o) in doc.overlay_entries() {
|
||||
if let Some(o) = o
|
||||
&& !added(num)
|
||||
{
|
||||
collect_refs(&o, &mut |r| stack.push(r));
|
||||
}
|
||||
}
|
||||
while let Some(r) = stack.pop() {
|
||||
if let Some(o) = pending.remove(&r.num) {
|
||||
collect_refs(&o, &mut |r| stack.push(r));
|
||||
}
|
||||
}
|
||||
pending.into_keys().collect()
|
||||
}
|
||||
|
||||
fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) {
|
||||
match o {
|
||||
Object::Ref(r) => f(*r),
|
||||
|
||||
@@ -228,6 +228,63 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
assert_qpdf_clean(&saved);
|
||||
}
|
||||
|
||||
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
|
||||
/// not written: the file never held it, and writing it would keep what was removed. Additions the
|
||||
/// update still refers to, from the trailer or a changed object and through other additions, are.
|
||||
#[test]
|
||||
fn incremental_save_leaves_out_additions_nothing_refers_to() {
|
||||
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
|
||||
let text = |s: &str| Object::String(pdfcraft_cos::PdfString::literal(s.as_bytes().to_vec()));
|
||||
let dict = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone()));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let page_obj = ObjRef::new(3, 0);
|
||||
let leaf = doc.add(text("kept leaf"));
|
||||
let kept = doc.add(dict(&[("Leaf", Object::Ref(leaf))]));
|
||||
doc.update_dict(page_obj, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(kept))).unwrap();
|
||||
let by_trailer = doc.add(text("kept by the trailer"));
|
||||
doc.trailer_mut().set(b"Custom".to_vec(), Object::Ref(by_trailer));
|
||||
// Linked from the page and unlinked again, as clearing a mark does.
|
||||
let dropped_leaf = doc.add(text("dropped leaf"));
|
||||
let dropped = doc.add(dict(&[("Leaf", Object::Ref(dropped_leaf))]));
|
||||
doc.update_dict(page_obj, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(dropped)]))).unwrap();
|
||||
doc.update_dict(page_obj, |d| {
|
||||
d.remove(b"Annots");
|
||||
})
|
||||
.unwrap();
|
||||
// Unlinked additions that refer to each other.
|
||||
let first = doc.add(Object::Null);
|
||||
let second = doc.add(dict(&[("Next", Object::Ref(first)), ("Note", text("dropped cycle"))]));
|
||||
doc.set(first, dict(&[("Next", Object::Ref(second))]));
|
||||
|
||||
// Off by default: a writer may add what only it finds again, by scanning.
|
||||
let kept_all = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(kept_all.windows(12).any(|w| w == b"dropped leaf"));
|
||||
let saved = write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() }).unwrap();
|
||||
let holds = |s: &str| saved.windows(s.len()).any(|w| w == s.as_bytes());
|
||||
assert!(holds("kept leaf") && holds("kept by the trailer"));
|
||||
assert!(!holds("dropped leaf") && !holds("dropped cycle"), "unlinked additions are written");
|
||||
let back = Document::open(Arc::new(saved.clone())).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(back.get(leaf).as_string().map(|s| s.bytes.as_slice()), Some(b"kept leaf".as_slice()));
|
||||
for gone in [dropped, dropped_leaf, first, second] {
|
||||
assert_eq!(*back.get(gone), Object::Null);
|
||||
}
|
||||
assert!(!page(&back).contains(b"Annots"));
|
||||
assert_qpdf_clean(&saved);
|
||||
|
||||
// Freed beforehand, they are written the same way without the option, and what is added
|
||||
// after that, nothing referring to it either (a document timestamp), is kept.
|
||||
let mut freed = doc.clone();
|
||||
freed.free_unlinked_additions();
|
||||
assert_eq!(write_incremental(&freed, &SaveOptions::default()).unwrap(), saved, "the same objects, freed the same way");
|
||||
freed.add(text("added after"));
|
||||
let stamped = write_incremental(&freed, &SaveOptions::default()).unwrap();
|
||||
assert!(stamped.windows(11).any(|w| w == b"added after") && !stamped.windows(12).any(|w| w == b"dropped leaf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qpdf_accepts_object_stream_output() {
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
|
||||
@@ -550,6 +550,23 @@ pub fn group(id: &str) -> Option<&'static ToolGroup> {
|
||||
TOOL_GROUPS.iter().find(|g| g.id == id)
|
||||
}
|
||||
|
||||
/// A heading of the expanded All tools view, as Acrobat files its tools under "Create & Edit",
|
||||
/// "Forms & Signatures" and so on. Every group belongs to exactly one category.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ToolCategory {
|
||||
pub label: &'static str,
|
||||
/// [`ToolGroup::id`]s, in display order.
|
||||
pub groups: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub static TOOL_CATEGORIES: &[ToolCategory] = &[
|
||||
ToolCategory { label: "Create & Edit", groups: &["create", "edit", "export", "combine", "organize", "scan", "compress"] },
|
||||
ToolCategory { label: "Review & Compare", groups: &["comment", "stamp", "compare", "measure"] },
|
||||
ToolCategory { label: "Forms & Signatures", groups: &["fill_sign", "form", "certificate"] },
|
||||
ToolCategory { label: "Protect & Standardize", groups: &["protect", "redact", "accessibility", "standards", "print_production"] },
|
||||
ToolCategory { label: "Customize & Automate", groups: &["actions", "javascript", "search_index", "ai"] },
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -564,6 +581,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_group_is_in_exactly_one_category() {
|
||||
let mut seen = HashSet::new();
|
||||
for c in TOOL_CATEGORIES {
|
||||
for id in c.groups {
|
||||
assert!(group(id).is_some(), "{} lists unknown group {id}", c.label);
|
||||
assert!(seen.insert(*id), "{id} is in two categories");
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), TOOL_GROUPS.len(), "a group is in no category");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_namespaced() {
|
||||
for g in TOOL_GROUPS {
|
||||
|
||||
+52
-30
@@ -3006,7 +3006,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
|
||||
pdfcraft_annot::summaries(doc)
|
||||
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
|
||||
.into_iter()
|
||||
.map(|s| pdfcraft_render::Annotation {
|
||||
page: s.page,
|
||||
@@ -3016,6 +3016,8 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)),
|
||||
name: s.name,
|
||||
in_reply_to: s.in_reply_to,
|
||||
parent: s.parent,
|
||||
grouped: s.grouped,
|
||||
rect: s.rect,
|
||||
color: s.color,
|
||||
index: s.index,
|
||||
@@ -3027,7 +3029,9 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
list
|
||||
}
|
||||
|
||||
/// Seconds to add to UTC for local time (0 where unknown).
|
||||
@@ -3067,16 +3071,24 @@ fn renderer_password(bytes: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as it may be written to a file (#3926): a copy without the values of text fields
|
||||
/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231
|
||||
/// (a note to the Password flag): "it is imperative that PDF processors never store the value of
|
||||
/// the text field in the PDF file if this flag is set". The open document keeps them in memory.
|
||||
/// The document as it may be written to a file (#3926, #3944): a copy without the values of text
|
||||
/// fields with the Password flag (or XFA `<passwordEdit/>` fields in the datasets), or `cos`
|
||||
/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag):
|
||||
/// "it is imperative that PDF processors never store the value of the text field in the PDF file
|
||||
/// if this flag is set". The open document keeps them in memory.
|
||||
fn writable(cos: &pdfcraft_cos::Document) -> Result<std::borrow::Cow<'_, pdfcraft_cos::Document>, EditError> {
|
||||
if !pdfcraft_forms::has_password_values(cos) {
|
||||
let has_form_pw = pdfcraft_forms::has_password_values(cos);
|
||||
let has_xfa_pw = pdfcraft_xfa::has_password_values(cos);
|
||||
if !has_form_pw && !has_xfa_pw {
|
||||
return Ok(std::borrow::Cow::Borrowed(cos));
|
||||
}
|
||||
let mut copy = cos.clone();
|
||||
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
if has_form_pw {
|
||||
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
if has_xfa_pw {
|
||||
pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
Ok(std::borrow::Cow::Owned(copy))
|
||||
}
|
||||
|
||||
@@ -4462,7 +4474,8 @@ impl Session {
|
||||
if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) {
|
||||
return Ok(editor.saved.clone());
|
||||
}
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// What the user removed again before saving (a cleared redaction mark) stays out of the file.
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() };
|
||||
// A restored snapshot can be based on an earlier revision. Append to the last saved
|
||||
// file so that revision stays in the chain. A full rewrite (redaction, new encryption,
|
||||
// a reconstructed file) still writes the current graph on its own.
|
||||
@@ -4512,11 +4525,15 @@ impl Session {
|
||||
// An encrypted file needs the password it is protected with now (the owner
|
||||
// password when known, so saving never downgrades this session's rights).
|
||||
let keys = editor.keys.clone();
|
||||
// The saved file carries no Password-field values (#3943); the open document keeps them.
|
||||
// The saved file carries no Password-field values (#3943, #3944); the open document keeps them.
|
||||
let typed = pdfcraft_forms::password_values(&editor.cos);
|
||||
editor.cos =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
if !typed.is_empty() && doc.info.xfa.is_some() {
|
||||
let mut datasets = None;
|
||||
let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets);
|
||||
}
|
||||
editor.saved = bytes;
|
||||
editor.saved_keys = keys;
|
||||
editor.unsaved_file = false;
|
||||
@@ -5121,17 +5138,18 @@ impl Session {
|
||||
let mut out = Vec::new();
|
||||
for d in &mut self.docs {
|
||||
if d.dirty && d.generation != d.snapshot_generation {
|
||||
// The working bytes hold typed Password-field values (#3926): a recovery file is
|
||||
// written from a copy without them, and none is written if that copy fails.
|
||||
let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
// The working bytes hold typed Password-field values (#3926, #3944): a recovery file
|
||||
// is written from a copy without them, and none is written if that copy fails.
|
||||
let bytes =
|
||||
match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
d.snapshot_generation = d.generation;
|
||||
out.push(RecoverySnapshot {
|
||||
doc: d.id,
|
||||
@@ -5503,24 +5521,30 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
/// pop-ups; the number is the comment's position on its page.
|
||||
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
|
||||
use std::fmt::Write;
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect();
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
|
||||
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
|
||||
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
|
||||
let mut children = Children::new();
|
||||
for r in all.iter().filter(|r| r.state.is_none()) {
|
||||
if let Some(parent) = r.parent {
|
||||
children.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
|
||||
// direct parent, so following one level would leave the deeper replies out of the summary.
|
||||
fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) {
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) {
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 1);
|
||||
let _ =
|
||||
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
|
||||
for line in r.contents.as_deref().unwrap_or("").lines() {
|
||||
let _ = writeln!(out, "{indent}{line}");
|
||||
}
|
||||
if let Some(nm) = r.name.as_deref() {
|
||||
write_replies(out, all, nm, depth + 1);
|
||||
}
|
||||
write_replies(out, children, r, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
|
||||
@@ -5559,9 +5583,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
|
||||
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
|
||||
let _ = writeln!(out, "{c}");
|
||||
}
|
||||
if let Some(nm) = a.name.as_deref() {
|
||||
write_replies(&mut out, all, nm, 0);
|
||||
}
|
||||
write_replies(&mut out, &children, a, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
|
||||
@@ -87,8 +87,9 @@ impl OptimizeJob {
|
||||
|
||||
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
|
||||
let OptimizeJob { mut cos, settings, discard, opts } = self;
|
||||
// Like every file written, without the values of Password-flag fields (#3926).
|
||||
// Like every file written, without the values of Password-flag fields (#3926, #3944).
|
||||
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
|
||||
@@ -2284,6 +2284,82 @@ fn a_signed_file_carries_no_password_field_value() {
|
||||
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
|
||||
}
|
||||
|
||||
/// #3944: a value typed into an XFA `<passwordEdit/>` field stays in the open document (both the
|
||||
/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`),
|
||||
/// but is written to no file — neither on the widget nor in the XFA datasets stream.
|
||||
#[test]
|
||||
fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() {
|
||||
let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap();
|
||||
let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
|
||||
|
||||
// Edit the password twice so an earlier edit's datasets stream could not linger in the overlay.
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap();
|
||||
|
||||
let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap();
|
||||
assert!(field.has(pdfcraft_forms::flags::PASSWORD));
|
||||
assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value");
|
||||
assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets");
|
||||
assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place");
|
||||
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}");
|
||||
assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}");
|
||||
assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}");
|
||||
}
|
||||
let snaps = s.autosave_snapshots();
|
||||
assert_eq!(snaps.len(), 1);
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}");
|
||||
}
|
||||
|
||||
// Reopening the saved file in a new session leaves the password field empty (it does not
|
||||
// repopulate from the XFA datasets on open).
|
||||
let mut t = Session::new();
|
||||
let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap();
|
||||
assert!(
|
||||
t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()),
|
||||
"reopened XFA form must have an empty password field"
|
||||
);
|
||||
|
||||
// Recording the save keeps the typed password in the open document (and its live datasets),
|
||||
// while a subsequent save still omits it.
|
||||
s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]);
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}");
|
||||
}
|
||||
|
||||
// Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is
|
||||
// overwritten in place rather than left behind.
|
||||
s.undo(id).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap();
|
||||
let saved_after_undo = s.save_bytes(id).unwrap();
|
||||
for leak in ["hunter2", "secret1", "secret3", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}");
|
||||
}
|
||||
|
||||
// A relayout (`rerender`) after typing a password frees the replaced generated widget and its
|
||||
// masked appearance stream so neither remains in the overlay when saving.
|
||||
let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace(
|
||||
r#"<subform name="page1" layout="tb">"#,
|
||||
r#"<subform name="page1" layout="tb"><field name="grow" w="2in"><ui><textEdit multiLine="1"/></ui></field>"#,
|
||||
);
|
||||
let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap();
|
||||
let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
|
||||
let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone();
|
||||
s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap();
|
||||
s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap();
|
||||
let saved_relayout = s.save_bytes(id2).unwrap();
|
||||
for leak in ["s3cr3t!", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
|
||||
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
|
||||
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
|
||||
@@ -2654,6 +2730,84 @@ fn redaction_marks_apply_for_good_and_undo() {
|
||||
assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_))));
|
||||
}
|
||||
|
||||
/// What [`mark_with_reply`] writes that must not reach a file once the mark is removed.
|
||||
const MARK_TRACES: [&str; 5] = ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"];
|
||||
|
||||
/// A redaction mark on page 1 (the first annotation there) and a reply to it.
|
||||
fn mark_with_reply(s: &mut Session, id: DocId) {
|
||||
let shape = Shape::Redact {
|
||||
quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])],
|
||||
overlay: "MARKED OVERLAY".into(),
|
||||
look: Default::default(),
|
||||
};
|
||||
let mark = Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
style: Style::default_for(&shape),
|
||||
shape,
|
||||
contents: String::new(),
|
||||
author: "Mark Author".into(),
|
||||
});
|
||||
s.apply(id, mark).unwrap();
|
||||
let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() };
|
||||
s.apply(id, reply).unwrap();
|
||||
}
|
||||
|
||||
/// Marks removed before they are applied, all at once or one by one, are not saved, nor are
|
||||
/// replies to them: the file would otherwise record where they were, their overlay text and who
|
||||
/// made them, and the page is left as it was.
|
||||
#[test]
|
||||
fn removed_redaction_marks_are_not_saved() {
|
||||
for clear in [true, false] {
|
||||
let (mut s, id) = session_with(1);
|
||||
mark_with_reply(&mut s, id);
|
||||
let removal = if clear { Edit::ClearRedactions } else { Edit::DeleteAnnotation { page: 0, index: 0 } };
|
||||
s.apply(id, removal).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().redaction_marks(), 0);
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
for trace in MARK_TRACES {
|
||||
assert!(!saved.windows(trace.len()).any(|w| w == trace.as_bytes()), "clear: {clear}, {trace:?}");
|
||||
}
|
||||
let mut reopened = Session::new();
|
||||
let back = reopened.open("saved.pdf", None, saved.clone(), None).unwrap();
|
||||
assert_eq!(page_texts(&reopened, back), ["Page 1"]);
|
||||
let page = pdfcraft_model::pages(&reopened.get(back).unwrap().editor.as_ref().unwrap().cos).swap_remove(0);
|
||||
assert!(!page.dict.contains(b"Annots"), "clear: {clear}");
|
||||
|
||||
// Undo past that save brings the mark back, under the number the saved file left free.
|
||||
s.mark_saved(id, saved, None).unwrap();
|
||||
s.undo(id).unwrap();
|
||||
let again = s.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
let back = reopened.open("again.pdf", None, again.clone(), None).unwrap();
|
||||
let d = reopened.get(back).unwrap();
|
||||
assert!(d.info.warnings.is_empty() && d.editor.as_ref().unwrap().cos.repair_log().is_empty(), "{:?}", d.info.warnings);
|
||||
assert_eq!(d.redaction_marks(), 1, "clear: {clear}");
|
||||
let marks = pdfcraft_redact::marks(&d.editor.as_ref().unwrap().cos);
|
||||
assert_eq!(marks.first().map(|m| m.overlay.as_str()), Some("MARKED OVERLAY"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removed marks are not in a signed file either, nor in one with revocation evidence embedded:
|
||||
/// no later save could take them out of those.
|
||||
#[test]
|
||||
fn removed_redaction_marks_are_not_signed() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
// After the test certificate's start date (2026-10-02).
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
mark_with_reply(&mut s, id);
|
||||
s.apply(id, Edit::ClearRedactions).unwrap();
|
||||
let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
|
||||
let evidence = sign::dss::Evidence { certs: vec![digital_id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() };
|
||||
let ltv = s.embed_ltv(id, &evidence).unwrap();
|
||||
for (what, out) in [("signed", signed), ("LTV", ltv)] {
|
||||
for trace in MARK_TRACES {
|
||||
assert!(!out.windows(trace.len()).any(|w| w == trace.as_bytes()), "{what}: {trace:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_redaction_overlay_preserves_the_document_and_undo() {
|
||||
let (mut s, id) = session_with(1);
|
||||
@@ -3721,6 +3875,78 @@ fn comments_lock_take_checkmarks_hide_and_summarize() {
|
||||
assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n");
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the
|
||||
/// annotation itself, or to a link (never listed as a comment), leaves it a comment of its own. Both readers (inspect on open, and the
|
||||
/// comment list the engine refreshes after an edit) must agree.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_names_repeat_across_pages() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R 9 0 R 10 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj
|
||||
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (fitz-A2) /IRT 9 0 R /T (Echo) /Contents (LINKED) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fitz.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let check = |s: &Session, replies: usize| {
|
||||
let all = &s.get(id).unwrap().info.annotations;
|
||||
let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap();
|
||||
assert_eq!(answer.parent, Some((1, 0)), "{answer:?}");
|
||||
let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap();
|
||||
assert!(!looped.is_reply(), "{looped:?}");
|
||||
let linked = all.iter().find(|a| a.contents.as_deref() == Some("LINKED")).unwrap();
|
||||
assert!(!linked.is_reply(), "{linked:?}");
|
||||
let text = comment_summary("fitz.pdf", all, SummarySort::Page);
|
||||
let (cover, second) = text.split_once("SECOND").unwrap();
|
||||
assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}");
|
||||
assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}");
|
||||
assert!(cover.contains("Number: 3 Author: Echo"), "so is the reply to a link: {text}");
|
||||
assert_eq!(second.matches(" Author:").count(), replies, "{text}");
|
||||
};
|
||||
check(&s, 1);
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "LATER".into(), author: "Delta".into() }).unwrap();
|
||||
check(&s, 2);
|
||||
}
|
||||
|
||||
/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its
|
||||
/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open.
|
||||
#[test]
|
||||
fn replies_thread_through_a_reference_chain_after_an_edit() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
5 0 obj 6 0 R endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let check = |s: &Session| {
|
||||
let all = &s.get(id).unwrap().info.annotations;
|
||||
let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed");
|
||||
assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}");
|
||||
for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] {
|
||||
let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed");
|
||||
assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}");
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap();
|
||||
check(&s);
|
||||
}
|
||||
|
||||
/// #820: the generated summary includes replies to replies, indented one level deeper.
|
||||
#[test]
|
||||
fn comment_summary_includes_replies_to_replies() {
|
||||
|
||||
+22
-5
@@ -1253,6 +1253,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
[one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
|
||||
_ => None,
|
||||
};
|
||||
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
let mut undrawn = false;
|
||||
for w in &f.widgets {
|
||||
let (stream, missing) = match &shown {
|
||||
@@ -1260,7 +1261,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
None => appearance::field_appearance_drawn(doc, f, w, values, true),
|
||||
};
|
||||
undrawn |= missing.is_some();
|
||||
let ap = doc.add(Object::Stream(stream));
|
||||
let existing_ap = in_place
|
||||
.then(|| {
|
||||
doc.get(w.obj)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"AP").map(|a| doc.resolve(a)))
|
||||
.and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| n.as_ref())
|
||||
.filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_)))
|
||||
})
|
||||
.flatten();
|
||||
let ap = match existing_ap {
|
||||
Some(r) => {
|
||||
doc.set(r, Object::Stream(stream));
|
||||
r
|
||||
}
|
||||
None => doc.add(Object::Stream(stream)),
|
||||
};
|
||||
let mut fresh = Dict::new();
|
||||
fresh.set(b"N".to_vec(), Object::Ref(ap));
|
||||
let apd = merged_appearance(doc, w.obj, &fresh);
|
||||
@@ -1315,7 +1332,7 @@ fn password_fields(doc: &Document) -> Vec<Field> {
|
||||
/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save
|
||||
/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like
|
||||
/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be
|
||||
/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep.
|
||||
/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep.
|
||||
fn has_password_flag(doc: &Document) -> bool {
|
||||
let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0);
|
||||
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
|
||||
@@ -1323,7 +1340,7 @@ fn has_password_flag(doc: &Document) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some((o, depth)) = stack.pop() {
|
||||
let Object::Ref(r) = o else { continue };
|
||||
if depth > 32 || !seen.insert(r) {
|
||||
if depth > 64 || !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if sets_it(r) {
|
||||
@@ -1363,11 +1380,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats.
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats.
|
||||
fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
|
||||
let mut chain = vec![r];
|
||||
let mut cur = r;
|
||||
while chain.len() < 32 {
|
||||
while chain.len() < 64 {
|
||||
let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break };
|
||||
if chain.contains(&parent) {
|
||||
break;
|
||||
|
||||
@@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag`
|
||||
/// and `has_password_values`, matching the field tree walk limit of 64.
|
||||
#[test]
|
||||
fn a_password_field_nested_beyond_depth_32_is_recognized() {
|
||||
let target_depth = 35;
|
||||
let leaf_obj_num = 5 + target_depth;
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
];
|
||||
for d in 0..target_depth {
|
||||
let obj_num = 5 + d;
|
||||
let next_obj_num = obj_num + 1;
|
||||
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
|
||||
objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
|
||||
}
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
|
||||
leaf_obj_num - 1
|
||||
));
|
||||
let mut doc = document(&objs);
|
||||
assert!(has_password_flag(&doc));
|
||||
assert!(has_password_values(&doc));
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
|
||||
assert!(!has_password_values(&doc));
|
||||
}
|
||||
|
||||
/// A password field whose widget is reachable through a page's Annots and whose inherited Password
|
||||
/// flag is deeper than 32 levels is recognized by `has_password_flag`.
|
||||
#[test]
|
||||
fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() {
|
||||
let target_depth = 35;
|
||||
let leaf_obj_num = 5 + target_depth;
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
];
|
||||
for d in 0..target_depth {
|
||||
let obj_num = 5 + d;
|
||||
let next_obj_num = obj_num + 1;
|
||||
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
|
||||
let ff_entry = if d == 0 { " /Ff 8192" } else { "" };
|
||||
objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
|
||||
}
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
|
||||
leaf_obj_num - 1
|
||||
));
|
||||
let mut doc = document(&objs);
|
||||
assert!(has_password_flag(&doc));
|
||||
assert!(has_password_values(&doc));
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
|
||||
assert!(!has_password_values(&doc));
|
||||
}
|
||||
|
||||
/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut
|
||||
/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits.
|
||||
#[test]
|
||||
|
||||
+12
-15
@@ -699,27 +699,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Remove redaction marks without applying them (`None` = all).
|
||||
/// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as
|
||||
/// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`.
|
||||
/// Returns how many marks went.
|
||||
pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize, RedactError> {
|
||||
let all = marks(doc);
|
||||
let doomed: Vec<ObjRef> = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect();
|
||||
if doomed.is_empty() {
|
||||
let targets: HashSet<ObjRef> = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(RedactError::NothingToApply);
|
||||
}
|
||||
let listed: Vec<Object> = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect();
|
||||
let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied());
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = annots_of(doc, &p.dict);
|
||||
let kept: Vec<Object> = annots
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
let Some(r) = a.as_ref() else { return true };
|
||||
let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref);
|
||||
!doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let kept: Vec<Object> = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect();
|
||||
// As deleting a comment does: a page whose marks were all it had loses `/Annots` again,
|
||||
// and an indirect array stays the one object the page points to.
|
||||
if kept.len() != annots.len() {
|
||||
doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?;
|
||||
pdfcraft_annot::set_annots(doc, p.obj, kept)?;
|
||||
}
|
||||
}
|
||||
Ok(doomed.len())
|
||||
Ok(targets.len())
|
||||
}
|
||||
|
||||
@@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
|
||||
+ owned(Hidden::Attachments)
|
||||
}
|
||||
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
|
||||
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
|
||||
Hidden::FormFields => {
|
||||
let n = pdfcraft_forms::fields(doc).len();
|
||||
if n > 0 {
|
||||
n
|
||||
} else if cat.contains(b"AcroForm") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
|
||||
Hidden::HiddenLayers => {
|
||||
let off = hidden_layers(doc).len();
|
||||
@@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
|
||||
// widgets included) leaves the structure tree too.
|
||||
let listed_before = listed_annotations(doc);
|
||||
let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0;
|
||||
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm"));
|
||||
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform);
|
||||
if fields {
|
||||
// Fields without appearances get one first, so their values stay visible.
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
@@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
cat.set(b"OCProperties".to_vec(), Object::Dict(oc));
|
||||
}
|
||||
}
|
||||
Hidden::FormFields => {
|
||||
cat.remove(b"AcroForm");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let n = count(h);
|
||||
let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) };
|
||||
if n > 0 {
|
||||
done.push((h, n));
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,41 @@ fn nothing_to_apply_and_clearing_marks() {
|
||||
assert!(content(&doc, 0).contains("(AB) Tj"));
|
||||
}
|
||||
|
||||
/// Clearing marks puts a page's annotation list back as it was: a page that had none has no
|
||||
/// `/Annots` again, replies to the marks included, and one listed through an indirect array keeps
|
||||
/// pointing at that array.
|
||||
#[test]
|
||||
fn clearing_marks_leaves_the_annotation_list_as_it_was() {
|
||||
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]);
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let before = doc.get(page);
|
||||
mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], "");
|
||||
pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap();
|
||||
// And a reply written inline, as a file can.
|
||||
let mut inline = Dict::new();
|
||||
inline.set(b"Subtype".to_vec(), Object::name("Text"));
|
||||
inline.set(b"IRT".to_vec(), Object::Ref(marks(&doc)[0].obj));
|
||||
let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap());
|
||||
listed.push(Object::Dict(inline));
|
||||
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap();
|
||||
assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark");
|
||||
assert_eq!(doc.get(page), before, "the page has no /Annots again: both replies went with their mark");
|
||||
|
||||
let mut doc = pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots 4 0 R >>".to_vec(),
|
||||
b"[5 0 R]".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(),
|
||||
]);
|
||||
let (page, list) = (ObjRef::new(3, 0), ObjRef::new(4, 0));
|
||||
let before = (doc.get(page), doc.get(list));
|
||||
mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], "");
|
||||
assert_eq!(doc.get(list).as_array().map(|a| a.len()), Some(2), "the mark is listed in the shared array");
|
||||
clear_marks(&mut doc, None).unwrap();
|
||||
assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_content_fails_closed() {
|
||||
let mut doc = one_page(b"", "", vec![]);
|
||||
@@ -2673,6 +2708,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file()
|
||||
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
|
||||
}
|
||||
|
||||
/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no
|
||||
/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save.
|
||||
#[test]
|
||||
fn removing_form_fields_removes_acroform_even_without_terminal_fields() {
|
||||
use crate::sanitize::{Hidden, remove_hidden, scan};
|
||||
let mut doc =
|
||||
one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]);
|
||||
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap();
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap();
|
||||
let counts = scan(&doc);
|
||||
assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1));
|
||||
assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
|
||||
assert!(!doc.full_save_required());
|
||||
|
||||
let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap();
|
||||
assert_eq!(done, [(Hidden::FormFields, 1)]);
|
||||
assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
|
||||
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
|
||||
assert!(doc.full_save_required());
|
||||
}
|
||||
|
||||
/// Removing hidden text cleans the structure tree as redaction does: the elements describing the
|
||||
/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy.
|
||||
#[test]
|
||||
|
||||
+222
-104
@@ -6,6 +6,7 @@
|
||||
//! are empty and `warnings` says why.
|
||||
|
||||
use std::cell::OnceCell;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -424,9 +425,17 @@ pub struct Annotation {
|
||||
pub author: Option<String>,
|
||||
pub contents: Option<String>,
|
||||
pub modified: Option<String>,
|
||||
/// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading.
|
||||
/// `/NM` of this annotation, and of the one it replies to (`/IRT`).
|
||||
pub name: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position of the comment `/IRT` points at, found by object
|
||||
/// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF
|
||||
/// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed
|
||||
/// comment ([`link_replies`]).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its
|
||||
/// strikeout) rather than a reply to it.
|
||||
pub grouped: bool,
|
||||
/// Rect in PDF user space: [x0, y0, x1, y1].
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
@@ -458,6 +467,23 @@ impl Annotation {
|
||||
matches!(self.state.as_deref(), Some("Marked" | "Unmarked"))
|
||||
}
|
||||
|
||||
/// A reply (or status reply) to another comment rather than a comment of its own.
|
||||
pub fn is_reply(&self) -> bool {
|
||||
self.parent.is_some()
|
||||
}
|
||||
|
||||
/// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't
|
||||
/// be found (deleted from another page, or never listed) has nothing to show, so it is listed
|
||||
/// nowhere: not as a card, on the page or in a count.
|
||||
pub fn is_comment(&self) -> bool {
|
||||
!self.is_reply() && self.state.is_none()
|
||||
}
|
||||
|
||||
/// Whether this annotation's `/IRT` points at `other`.
|
||||
pub fn replies_to(&self, other: &Annotation) -> bool {
|
||||
self.parent == Some((other.page, other.index))
|
||||
}
|
||||
|
||||
/// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user
|
||||
/// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner
|
||||
/// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment
|
||||
@@ -511,6 +537,55 @@ fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] {
|
||||
[x0, y1 - h, x0 + w, y1]
|
||||
}
|
||||
|
||||
/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when
|
||||
/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted
|
||||
/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays
|
||||
/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list
|
||||
/// after an edit) run this.
|
||||
///
|
||||
/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other
|
||||
/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an
|
||||
/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list
|
||||
/// has them.
|
||||
pub fn link_replies(annotations: &mut [Annotation]) {
|
||||
let at: HashMap<(usize, usize), usize> =
|
||||
annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect();
|
||||
let links: Vec<Option<usize>> =
|
||||
annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect();
|
||||
for (i, a) in annotations.iter_mut().enumerate() {
|
||||
let mut up = links.get(i).copied().flatten();
|
||||
let mut settled = false;
|
||||
for _ in 0..64 {
|
||||
let Some(j) = up else { break };
|
||||
up = links.get(j).copied().flatten();
|
||||
if up.is_none() {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !settled {
|
||||
a.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a
|
||||
/// reply to a reply files under that comment too. Expects links [`link_replies`] settled.
|
||||
pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> {
|
||||
let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect();
|
||||
parents
|
||||
.iter()
|
||||
.map(|(&reply, &first)| {
|
||||
let mut top = first;
|
||||
for _ in 0..64 {
|
||||
let Some(&up) = parents.get(&top) else { break };
|
||||
top = up;
|
||||
}
|
||||
(reply, top)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
@@ -1332,13 +1407,12 @@ impl<'a> Inspector<'a> {
|
||||
pages.sort_unstable_by_key(|&(_, page)| page);
|
||||
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
|
||||
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
|
||||
// a reference resolves to, since an indirect object may itself hold a reference.
|
||||
// a reference resolves to, since an indirect object may itself hold a reference. Where each
|
||||
// annotation is listed is collected before any is read, so a reply's `/IRT` finds its
|
||||
// parent by reference even when the parent comes later.
|
||||
let mut seen_arrays = HashSet::new();
|
||||
let mut seen_annotations = HashSet::new();
|
||||
let mut kept = 0usize;
|
||||
// Once the comment budget is spent, comments are no longer built, but links and attachments
|
||||
// are still listed.
|
||||
let mut comments_full = false;
|
||||
let mut slots: HashMap<ObjectId, (usize, usize)> = HashMap::new();
|
||||
let mut entries = Vec::new();
|
||||
for (id, page) in pages {
|
||||
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
|
||||
if let Ok(annots) = page_dict.get(b"Annots")
|
||||
@@ -1349,107 +1423,116 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
|
||||
for (index, a) in annots.iter().enumerate() {
|
||||
if let Some(r) = self.resolved_id(a)
|
||||
&& !seen_annotations.insert(r)
|
||||
{
|
||||
continue;
|
||||
if let Some(r) = self.resolved_id(a) {
|
||||
let Entry::Vacant(slot) = slots.entry(r) else { continue };
|
||||
slot.insert((page, index));
|
||||
}
|
||||
let Some(d) = self.dict(a) else { continue };
|
||||
let Some(subtype) = self.name(d, b"Subtype") else { continue };
|
||||
if subtype == "FileAttachment"
|
||||
&& let Ok(fs) = d.get(b"FS")
|
||||
{
|
||||
let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget);
|
||||
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
if let Some(link) = self.link(page, index, d, text_budget) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
info.links.push(link);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Not comments: form widgets, pop-ups and non-markup annotations such as the
|
||||
// Screen annotation that drives a LaTeX `animate` player (keep in step with
|
||||
// `pdfcraft_annot::is_comment_subtype`).
|
||||
if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if comments_full {
|
||||
continue;
|
||||
}
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
|
||||
let mut color = match d.get(b"C").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(c)) if c.len() == 3 => {
|
||||
let f = |i: usize| c[i].as_float().unwrap_or(0.0);
|
||||
Some([f(0), f(1), f(2)])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// A text box's /C is its background; its text colour is in /DA.
|
||||
if subtype == "FreeText"
|
||||
&& let Some(da) = self.text(d, b"DA")
|
||||
{
|
||||
let t: Vec<&str> = da.split_whitespace().collect();
|
||||
if let Some(i) = t.iter().position(|x| *x == "rg")
|
||||
&& i >= 3
|
||||
{
|
||||
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(q)) => {
|
||||
let v: Vec<f32> = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
v.as_chunks::<8>().0.to_vec()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
|
||||
let fill_sign = self.is_fill_sign(d, &subtype);
|
||||
// Keep in step with `pdfcraft_annot::summaries`.
|
||||
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
|
||||
let points = match d.get(key).map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
|
||||
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
|
||||
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let annotation = Annotation {
|
||||
page,
|
||||
subtype,
|
||||
author: self.text(d, b"T"),
|
||||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep
|
||||
// in step with `pdfcraft_annot::summaries`).
|
||||
contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||||
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
|
||||
name: self.text(d, b"NM"),
|
||||
in_reply_to,
|
||||
rect,
|
||||
color,
|
||||
index,
|
||||
state: self.text(d, b"State"),
|
||||
quads,
|
||||
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
|
||||
intent: self.name(d, b"IT"),
|
||||
fill_sign,
|
||||
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
|
||||
points,
|
||||
};
|
||||
kept = kept.saturating_add(retained(&annotation));
|
||||
if kept > MAX_ANNOTATION_BYTES {
|
||||
comments_full = true;
|
||||
continue;
|
||||
}
|
||||
info.annotations.push(annotation);
|
||||
entries.push((page, index, a));
|
||||
}
|
||||
}
|
||||
let mut kept = 0usize;
|
||||
// Once the comment budget is spent, comments are no longer built, but links and attachments
|
||||
// are still listed.
|
||||
let mut comments_full = false;
|
||||
for (page, index, a) in entries {
|
||||
let Some(d) = self.dict(a) else { continue };
|
||||
let Some(subtype) = self.name(d, b"Subtype") else { continue };
|
||||
if subtype == "FileAttachment"
|
||||
&& let Ok(fs) = d.get(b"FS")
|
||||
{
|
||||
let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget);
|
||||
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
if let Some(link) = self.link(page, index, d, text_budget) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
info.links.push(link);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Not comments: form widgets, pop-ups and non-markup annotations such as the
|
||||
// Screen annotation that drives a LaTeX `animate` player (keep in step with
|
||||
// `pdfcraft_annot::is_comment_subtype`).
|
||||
if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") {
|
||||
continue;
|
||||
}
|
||||
if comments_full {
|
||||
continue;
|
||||
}
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
|
||||
let mut color = match d.get(b"C").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(c)) if c.len() == 3 => {
|
||||
let f = |i: usize| c[i].as_float().unwrap_or(0.0);
|
||||
Some([f(0), f(1), f(2)])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// A text box's /C is its background; its text colour is in /DA.
|
||||
if subtype == "FreeText"
|
||||
&& let Some(da) = self.text(d, b"DA")
|
||||
{
|
||||
let t: Vec<&str> = da.split_whitespace().collect();
|
||||
if let Some(i) = t.iter().position(|x| *x == "rg")
|
||||
&& i >= 3
|
||||
{
|
||||
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(q)) => {
|
||||
let v: Vec<f32> = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
v.as_chunks::<8>().0.to_vec()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
|
||||
let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied());
|
||||
let fill_sign = self.is_fill_sign(d, &subtype);
|
||||
// Keep in step with `pdfcraft_annot::summaries`.
|
||||
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
|
||||
let points = match d.get(key).map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
|
||||
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
|
||||
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let annotation = Annotation {
|
||||
page,
|
||||
subtype,
|
||||
author: self.text(d, b"T"),
|
||||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep
|
||||
// in step with `pdfcraft_annot::summaries`).
|
||||
contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||||
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
|
||||
name: self.text(d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: self.name(d, b"RT").as_deref() == Some("Group"),
|
||||
rect,
|
||||
color,
|
||||
index,
|
||||
state: self.text(d, b"State"),
|
||||
quads,
|
||||
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
|
||||
intent: self.name(d, b"IT"),
|
||||
fill_sign,
|
||||
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
|
||||
points,
|
||||
};
|
||||
kept = kept.saturating_add(retained(&annotation));
|
||||
if kept > MAX_ANNOTATION_BYTES {
|
||||
comments_full = true;
|
||||
continue;
|
||||
}
|
||||
info.annotations.push(annotation);
|
||||
}
|
||||
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
link_replies(&mut info.annotations);
|
||||
}
|
||||
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
|
||||
@@ -2426,6 +2509,8 @@ mod tests {
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [40.0, 50.0, 70.0, 70.0],
|
||||
color: None,
|
||||
index: 0,
|
||||
@@ -2999,6 +3084,39 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply finds its comment where the listing keeps it: on the first page of a page-shared
|
||||
/// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it.
|
||||
#[test]
|
||||
fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() {
|
||||
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
|
||||
5 0 obj 6 0 R endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\
|
||||
7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\
|
||||
9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF";
|
||||
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let mut saved = Vec::new();
|
||||
doc.save_to(&mut saved).expect("saves");
|
||||
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
for reader in [&doc as &dyn Structure, &lazy] {
|
||||
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
|
||||
Inspector::new(reader).fill(&mut info);
|
||||
let notes = &info.annotations;
|
||||
assert_eq!(notes.len(), 3, "{notes:?}");
|
||||
let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed");
|
||||
assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false));
|
||||
for reply in ["to the comment", "through the chain"] {
|
||||
let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed");
|
||||
assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw
|
||||
/// length, so a string many entries share is converted only a few times.
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ pub mod transition;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
|
||||
@@ -93,7 +93,9 @@ pub fn embed(doc: &Document, evidence: &Evidence) -> Result<Vec<u8>, SignError>
|
||||
}
|
||||
let dss_ref = doc.add(Object::Dict(dss));
|
||||
doc.update_dict(root, |c| c.set(b"DSS".to_vec(), Object::Ref(dss_ref)))?;
|
||||
Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions::default())?)
|
||||
// What the user removed again before this (a cleared redaction mark) stays out of the file,
|
||||
// as in a save; the store and everything in it are reachable from the catalog.
|
||||
Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() })?)
|
||||
}
|
||||
|
||||
/// Store one DER blob as a `/Type /Embed` stream.
|
||||
|
||||
@@ -2202,8 +2202,11 @@ fn sign_inner(
|
||||
let mut apd = Dict::new();
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap_ref));
|
||||
doc.update_dict(widget, |w| w.set(b"AP".to_vec(), Object::Dict(apd)))?;
|
||||
// Write with the placeholders, then patch them.
|
||||
let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, ..SaveOptions::default() };
|
||||
// Write with the placeholders, then patch them. What the user removed again before signing
|
||||
// (a cleared redaction mark) stays out of the file, as in a save; what signing added is
|
||||
// reachable (the signature through the field's `/V`), and the placeholders are found in the
|
||||
// written bytes.
|
||||
let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, drop_unlinked: true, ..SaveOptions::default() };
|
||||
let mut out = pdfcraft_cos::write_incremental(&doc, &save)?;
|
||||
let (br_at, gap) = locate(&out, reserve)?;
|
||||
let (start, end) = gap;
|
||||
@@ -2252,6 +2255,10 @@ pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampA
|
||||
));
|
||||
}
|
||||
let mut doc = doc.clone();
|
||||
// What the user removed again before this (a cleared redaction mark) stays out of the file,
|
||||
// as in a save. Nothing refers to the timestamp either, so the leftovers go first and the
|
||||
// save keeps everything.
|
||||
doc.free_unlinked_additions();
|
||||
let mut v = Dict::new();
|
||||
v.set(b"Type".to_vec(), Object::name("DocTimeStamp"));
|
||||
v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite"));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, write_incremental};
|
||||
use pdfcraft_sign::{
|
||||
Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der,
|
||||
in_english, kind, pkcs12, signatures,
|
||||
@@ -815,6 +815,71 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() {
|
||||
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
|
||||
}
|
||||
|
||||
/// A redaction mark and a reply to it, added to the page and removed again before signing (what
|
||||
/// clearing a mark leaves behind), are in none of the signing outputs: a signature, a timestamped
|
||||
/// one, embedded revocation evidence or a document timestamp. Nothing can take them out of a
|
||||
/// signed revision afterwards. Each output still validates.
|
||||
#[test]
|
||||
fn what_was_removed_before_signing_is_not_signed() {
|
||||
use pdfcraft_sign::dss::{self, Evidence};
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } };
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] };
|
||||
let text = |s: &str| Object::String(PdfString::literal(s.as_bytes().to_vec()));
|
||||
let dict = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone()));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let cleared = |bytes: &[u8]| {
|
||||
let mut doc = open(bytes);
|
||||
let page = ObjRef::new(3, 0);
|
||||
let annots = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()).unwrap();
|
||||
let mark = doc.add(dict(&[
|
||||
("Type", Object::name("Annot")),
|
||||
("Subtype", Object::name("Redact")),
|
||||
("Rect", Object::Array([80, 140, 100, 180].map(Object::Int).to_vec())),
|
||||
("OverlayText", text("MARKED OVERLAY")),
|
||||
("T", text("Mark Author")),
|
||||
("P", Object::Ref(page)),
|
||||
]));
|
||||
let reply = doc.add(dict(&[
|
||||
("Type", Object::name("Annot")),
|
||||
("Subtype", Object::name("Text")),
|
||||
("IRT", Object::Ref(mark)),
|
||||
("Contents", text("REPLY TO THE MARK")),
|
||||
("T", text("Replier")),
|
||||
]));
|
||||
let mut with = annots.as_array().cloned().unwrap();
|
||||
with.extend([Object::Ref(mark), Object::Ref(reply)]);
|
||||
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(with))).unwrap();
|
||||
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), annots.clone())).unwrap();
|
||||
doc
|
||||
};
|
||||
let unsigned = fixture();
|
||||
let signed = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap();
|
||||
let evidence = Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() };
|
||||
let outputs = [
|
||||
("sign", &unsigned, pdfcraft_sign::sign(&cleared(&unsigned), &id, &opts()).unwrap()),
|
||||
("sign_with_timestamp", &unsigned, pdfcraft_sign::sign_with_timestamp(&cleared(&unsigned), &id, &opts(), &tsa).unwrap()),
|
||||
("dss::embed", &signed, dss::embed(&cleared(&signed), &evidence).unwrap()),
|
||||
("timestamp_document", &unsigned, pdfcraft_sign::timestamp_document(&cleared(&unsigned), &tsa, "D:20261006120000Z").unwrap()),
|
||||
];
|
||||
for (what, input, out) in outputs {
|
||||
assert!(out.starts_with(input), "{what}: an incremental update");
|
||||
let holds = |s: &str| out.windows(s.len()).any(|w| w == s.as_bytes());
|
||||
for trace in ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"] {
|
||||
assert!(!holds(trace), "{what} wrote {trace:?}");
|
||||
}
|
||||
let doc = open(&out);
|
||||
assert!(doc.repair_log().is_empty(), "{what}: {:?}", doc.repair_log());
|
||||
let all: Vec<_> = signatures(&doc, &out, &trust).into_iter().filter(|s| s.signed).collect();
|
||||
assert_eq!(all.iter().filter(|s| s.doc_timestamp).count(), usize::from(what == "timestamp_document"), "{what}");
|
||||
assert!(!all.is_empty() && all.iter().all(|s| s.status == Status::Valid), "{what}: {all:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_verified_revocation_invalidates_the_signature() {
|
||||
use pdfcraft_sign::dss::{self, Evidence};
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! The expanded All tools view: every tool, filed under Acrobat-style categories, with a search
|
||||
//! field, filling the workspace instead of the sidebar. The sidebar's expand button opens it;
|
||||
//! its collapse button, Esc or choosing a tool closes it again.
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, vec2};
|
||||
use pdfcraft_engine::catalog::{self, Availability, TOOL_CATEGORIES, ToolGroup};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
|
||||
const TILE: egui::Vec2 = vec2(148.0, 118.0);
|
||||
/// Item names a tile lists when the search found the tool by them.
|
||||
const MATCHES_SHOWN: usize = 2;
|
||||
|
||||
/// Whether `query` (already trimmed and lower-cased) is in `label` or its translation.
|
||||
/// A right-to-left language's translations are in display order (their words run the other way)
|
||||
/// while the query is in typing order, so there several words match one by one, as in the
|
||||
/// command palette.
|
||||
fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool {
|
||||
let translated = translated.to_lowercase();
|
||||
label.to_lowercase().contains(query)
|
||||
|| translated.contains(query)
|
||||
|| (rtl && query.contains(char::is_whitespace) && query.split_whitespace().all(|word| translated.contains(word)))
|
||||
}
|
||||
|
||||
/// Whether `group` matches `query` (already trimmed and lower-cased), in English or the UI
|
||||
/// language. `None` is no match; otherwise the items that matched, empty when the tool's own
|
||||
/// name did. An entry with a menu (Watermark: Add, Update, Remove) also matches by its commands'
|
||||
/// names, so "remove watermark" finds it.
|
||||
fn matches(query: &str, group: &ToolGroup) -> Option<Vec<&'static str>> {
|
||||
let rtl = crate::i18n::current().rtl();
|
||||
let hit = |label: &str| label_hit(label, tl!(label), query, rtl);
|
||||
if query.is_empty() || hit(group.label) {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let action_hit = |id: &&str| {
|
||||
pdfcraft_engine::commands::command(id).is_some_and(|spec| label_hit(spec.label, &crate::i18n::menu_label(spec.id, spec.label), query, rtl))
|
||||
};
|
||||
let items: Vec<_> =
|
||||
group.sections.iter().flat_map(|s| s.items.iter()).filter(|i| hit(i.label) || i.actions.iter().any(action_hit)).map(|i| i.label).collect();
|
||||
(!items.is_empty()).then_some(items)
|
||||
}
|
||||
|
||||
/// Draw the view over the workspace: the title with Collapse, the search field, and each
|
||||
/// category's tiles that match the search. Choosing a tile opens that tool's panel.
|
||||
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// A dialog, prompt or the command palette over the view owns Escape.
|
||||
if !app.modal_open() && !app.palette_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.all_tools_full = false;
|
||||
return;
|
||||
}
|
||||
let mut chosen = None;
|
||||
egui::Frame::NONE.fill(t.panel).show(ui, |ui| {
|
||||
ui.set_min_size(ui.available_size());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 20, bottom: 12 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("All tools")).font(theme::semibold(22.0)).color(t.text));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "minimize-2", 28.0, false, tl!("Collapse all tools")).clicked() {
|
||||
app.all_tools_full = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let field = ui.add(
|
||||
widgets::line(&mut app.all_tools_query).hint_text(tl!("Search tools and commands…")).desired_width(ui.available_width().min(520.0)),
|
||||
);
|
||||
// Typing goes straight to the search, as in Acrobat's tools view.
|
||||
if !ui.memory(|m| m.focused().is_some()) {
|
||||
field.request_focus();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let query = app.all_tools_query.trim().to_lowercase();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 8, bottom: 28 }).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let mut any = false;
|
||||
for category in TOOL_CATEGORIES {
|
||||
let found: Vec<_> =
|
||||
category.groups.iter().filter_map(|id| catalog::group(id)).filter_map(|g| matches(&query, g).map(|m| (g, m))).collect();
|
||||
if found.is_empty() {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(tl!(category.label)).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
for (g, items) in found {
|
||||
if tile(ui, &t, g, &items).clicked() {
|
||||
chosen = Some(g.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if !any {
|
||||
ui.add_space(24.0);
|
||||
ui.label(egui::RichText::new(tl!("No tools match your search.")).color(t.text_muted));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(id) = chosen {
|
||||
app.left_open = true;
|
||||
app.select_tool(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// One tool's tile: its icon and name, then the `items` the search found it by or, when there are
|
||||
/// none, its availability (a planned tool is greyed out with its milestone). A name or note cut
|
||||
/// short shows in full on hover.
|
||||
fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
crate::home::card_lift(ui, t, rect, press);
|
||||
ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
let body = rect.translate(press.offset());
|
||||
let ready = g.availability == Availability::Ready;
|
||||
let hue = Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
icons::paint(ui, Rect::from_center_size(body.center_top() + vec2(0.0, 34.0), vec2(32.0, 32.0)), g.icon, 30.0, hue);
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple(tl!(g.label).to_owned(), theme::medium(13.0), if ready { t.text } else { t.text_muted }, body.width() - 16.0);
|
||||
job.halign = egui::Align::Center;
|
||||
job.wrap.max_rows = 2;
|
||||
let title = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let mut elided = title.elided;
|
||||
let title_h = title.size().y;
|
||||
ui.painter().galley(body.center_top() + vec2(0.0, 60.0), title, t.text);
|
||||
// What found the tool, or else whether it works today.
|
||||
let note = if items.is_empty() {
|
||||
match g.availability {
|
||||
Availability::Ready => String::new(),
|
||||
Availability::Planned(m) => format!("{} · {m}", tl!("Planned")),
|
||||
Availability::Provider => g.badge.map(|b| tl!(b).to_owned()).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
items.iter().take(MATCHES_SHOWN).map(|l| tl!(l)).collect::<Vec<_>>().join(" · ")
|
||||
};
|
||||
if !note.is_empty() {
|
||||
let color = if items.is_empty() { t.text_faint } else { t.accent_text };
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(note.clone(), theme::regular(11.0), color);
|
||||
job.halign = egui::Align::Center;
|
||||
job.wrap = egui::text::TextWrapping { max_width: body.width() - 16.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let galley = ui.fonts_mut(|f| f.layout_job(job));
|
||||
elided |= galley.elided;
|
||||
ui.painter().galley(body.center_top() + vec2(0.0, 64.0 + title_h), galley, color);
|
||||
}
|
||||
if let Some(b) = g.badge.filter(|_| g.availability == Availability::Ready) {
|
||||
let font = theme::semibold(9.5);
|
||||
let w = widgets::text_width(ui, tl!(b), &font);
|
||||
let r = Rect::from_min_size(body.right_top() + vec2(-w - 18.0, 8.0), vec2(w + 10.0, 16.0));
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!(b), font, Color32::WHITE);
|
||||
}
|
||||
let resp = if elided { resp.on_hover_text(format!("{}\n{note}", tl!(g.label))) } else { resp };
|
||||
theme::hand(resp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn group(id: &str) -> &'static ToolGroup {
|
||||
catalog::group(id).expect("catalogue group")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_tool_names_and_their_items() {
|
||||
assert_eq!(matches("", group("organize")), Some(Vec::new()), "an empty query shows everything");
|
||||
assert_eq!(matches("organize", group("organize")), Some(Vec::new()), "the tool's own name");
|
||||
let rotate = matches("rotate", group("organize")).expect("Organize pages lists Rotate");
|
||||
assert!(!rotate.is_empty() && rotate.iter().all(|l| l.to_lowercase().contains("rotate")), "{rotate:?}");
|
||||
assert_eq!(matches("no such tool anywhere", group("organize")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_an_entry_by_its_menus_commands() {
|
||||
let found = matches("remove watermark", group("edit")).expect("Watermark's menu has Remove watermark");
|
||||
assert!(found.contains(&"Watermark"), "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_labels_match_a_query_typed_in_logical_order() {
|
||||
let shown = crate::bidi::display_rtl("تنظيم الصفحات");
|
||||
assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display");
|
||||
assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true));
|
||||
assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word");
|
||||
assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there");
|
||||
assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact");
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ fn explained(issues: &[CalculationIssue]) -> String {
|
||||
|
||||
/// The Calculation order page; Back returns to Prepare a form.
|
||||
pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let (back, close) = crate::panels::panel_header(ui, t, "Calculation order", Some(tl!("Back to Prepare a form")));
|
||||
let (back, close) = crate::panels::panel_header(ui, t, "Calculation order", Some(("chevron-left", tl!("Back to Prepare a form"))));
|
||||
if back {
|
||||
app.left = LeftPanel::Tool("form");
|
||||
}
|
||||
@@ -724,10 +724,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
/// The calculation order on page `page`: the links of the field in focus, with the fields it has
|
||||
/// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in
|
||||
/// focus, the steps of the fields whose place is wrong. A form with many calculations stays
|
||||
/// readable either way. Returns the field under the pointer.
|
||||
/// readable either way. Returns the field under the pages' `pointer`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn paint_page(
|
||||
ui: &egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
@@ -736,6 +735,7 @@ pub(crate) fn paint_page(
|
||||
view: &CalcOrderView,
|
||||
generation: u64,
|
||||
selected: Option<&str>,
|
||||
pointer: Option<Pos2>,
|
||||
) -> Option<String> {
|
||||
let listed = view.current(generation)?;
|
||||
// Each field's first widget on this page.
|
||||
@@ -749,7 +749,6 @@ pub(crate) fn paint_page(
|
||||
if on_page.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned());
|
||||
let at: HashMap<&str, Rect> = on_page.iter().copied().collect();
|
||||
let zoom = xf.zoom().clamp(0.5, 3.0);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2};
|
||||
|
||||
use crate::canvas::{DocView, PageXform, ViewAction};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
pub use pdfcraft_engine::camera_clean::Look;
|
||||
|
||||
@@ -58,9 +59,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 {
|
||||
}
|
||||
|
||||
/// Corner dragging on one page. A press on a page without the quadrilateral moves it there.
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView, allowed: bool) {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if !allowed {
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
|
||||
|
||||
@@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
|
||||
@@ -2373,7 +2374,7 @@ impl DocView {
|
||||
/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures)
|
||||
/// show none: they are page content being edited, not comments.
|
||||
fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
|
||||
if a.in_reply_to.is_some() || a.fill_sign {
|
||||
if a.is_reply() || a.fill_sign {
|
||||
return None;
|
||||
}
|
||||
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
|
||||
@@ -2791,7 +2792,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
None => {}
|
||||
}
|
||||
// No dialog, close prompt or palette over the page: only then does page input count.
|
||||
let unobstructed = !app.modal_open() && !app.palette_open;
|
||||
let modal = app.modal_open();
|
||||
let unobstructed = !modal && !app.palette_open;
|
||||
let organize_zoom = app.organize_zoom;
|
||||
let view = &mut app.views[index];
|
||||
if view.organize {
|
||||
@@ -2835,11 +2837,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// Full screen: a click on the document goes forward a page and a shift-click back, as in
|
||||
// Acrobat, unless the click did something else (a link, a word selected). Decided below,
|
||||
// once the pages have had the click.
|
||||
let present_click = view.presentation.is_some()
|
||||
&& navigation_enabled
|
||||
&& ui.rect_contains_pointer(navigation_rect)
|
||||
&& ui.input(|i| i.pointer.primary_clicked())
|
||||
&& app.quick_tool == QuickTool::Select;
|
||||
let present_click =
|
||||
view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select;
|
||||
// Shift as held now, or as the click's own event carried it (a synthetic click).
|
||||
let present_back = present_click
|
||||
&& ui.input(|i| {
|
||||
@@ -2850,7 +2849,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
});
|
||||
if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) {
|
||||
let z = view.zoom * zoom_delta;
|
||||
match ui.input(|i| i.pointer.hover_pos()) {
|
||||
match zoom_anchor(ui) {
|
||||
Some(p) => view.zoom_at(z, p),
|
||||
None => view.set_zoom(z),
|
||||
}
|
||||
@@ -2866,7 +2865,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view
|
||||
// that way, so a point beyond what is shown can be reached without letting go of the tool.
|
||||
let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) {
|
||||
let (pointer, dt) = ui.input(|i| (if i.pointer.any_down() { i.pointer.latest_pos() } else { i.pointer.hover_pos() }, i.stable_dt));
|
||||
let (pointer, dt) = drawing_edge_pointer(ui);
|
||||
crate::autoscroll::edge_scroll(pointer, avail, dt)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
@@ -3006,7 +3005,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;
|
||||
let over_page = ui.rect_contains_pointer(avail);
|
||||
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
|
||||
let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })));
|
||||
let interrupted = glide_interrupted(ui);
|
||||
if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
|
||||
view.fling.stop();
|
||||
}
|
||||
@@ -3164,15 +3163,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let mut open_signature = false;
|
||||
let mut hover_text: Option<(Pos2, String)> = None;
|
||||
let mut badge_hover: Option<(Pos2, String)> = None;
|
||||
// A dialog, save prompt or palette covers the page. Hover tips are drawn above those
|
||||
// windows, so a comment under the pointer would otherwise float over the dialog.
|
||||
let page_covered = app.dialog.is_some()
|
||||
|| app.close_request.is_some()
|
||||
|| app.palette_open
|
||||
|| app.password_prompt.is_some()
|
||||
|| app.pending_link.is_some()
|
||||
|| app.pending_remote_link.is_some()
|
||||
|| app.updates.open;
|
||||
let mut clicked_link: Option<LinkTarget> = None;
|
||||
let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None;
|
||||
let mut canvas_action: Option<comments::CanvasAction> = None;
|
||||
@@ -3261,11 +3251,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let mut best_overlap = -1.0f32;
|
||||
let mut current_overlap = -1.0f32;
|
||||
let mut current_height = f32::INFINITY;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
// The pointer as the pages see it: nothing over them (a dialog from the frame it opens),
|
||||
// or a press they hold. The page modules read it rather than egui's raw pointer state.
|
||||
let ptr = PagePointer::read(ui, &resp, modal);
|
||||
let pointer = ptr.pos;
|
||||
// A press on a page makes it the current one (as in Acrobat), so a page command with
|
||||
// no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page
|
||||
// gone to earlier that still shows whole beside it.
|
||||
let pressed = unobstructed && ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(avail);
|
||||
let pressed = unobstructed && ptr.pressed().is_some();
|
||||
let mut pressed_page = None;
|
||||
if resp.secondary_clicked() {
|
||||
// Set again below when the right-click lands on a link.
|
||||
@@ -3421,14 +3414,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref());
|
||||
if editing_content {
|
||||
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
|
||||
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
|
||||
view.move_background.paint(painter, &xf, i, info, &view.content, page_tex, pointer);
|
||||
}
|
||||
if view.show_page_gaps {
|
||||
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
|
||||
}
|
||||
|
||||
// Comments: tools, selection, moving and resizing come before text selection.
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
if let QuickTool::Measure(measure_tool) = tool {
|
||||
crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units);
|
||||
}
|
||||
@@ -3437,7 +3430,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// every viewing mode); then comments; then text selection.
|
||||
if let QuickTool::Stamp(kind) = tool
|
||||
&& allowed
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
@@ -3461,7 +3454,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
if let Some(cs) = custom_stamp.as_ref()
|
||||
&& allowed
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
@@ -3482,12 +3475,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
|
||||
if let QuickTool::Fill(ft) = tool
|
||||
&& allowed
|
||||
&& !comments::fill_grabs(ui, &pcx, view)
|
||||
&& !comments::fill_grabs(&pcx, view)
|
||||
{
|
||||
match crate::fill_sign::page_input(
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
ft,
|
||||
@@ -3511,15 +3505,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
if matches!(tool, QuickTool::SignArea { .. }) {
|
||||
crate::sign_ui::page_input(ui, &resp, &xf, i, info, view);
|
||||
crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view);
|
||||
}
|
||||
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
|
||||
crate::zoom_snap::page_input(ui, &resp, &xf, i, view);
|
||||
crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view);
|
||||
}
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify);
|
||||
crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify);
|
||||
}
|
||||
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) {
|
||||
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) {
|
||||
view.current = i;
|
||||
open_boxes = true;
|
||||
}
|
||||
@@ -3531,7 +3525,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// While a table is being set up, nothing else is drawn or moved.
|
||||
let allowed = can_modify && table_preview.is_none() && open_table.is_none();
|
||||
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view);
|
||||
let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view);
|
||||
field_props |= o.properties;
|
||||
field_placed |= o.placed;
|
||||
open_table = open_table.or(o.table);
|
||||
@@ -3539,10 +3533,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
use crate::forms_ui::FieldPointer;
|
||||
let field = if tool == QuickTool::Select
|
||||
&& !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view))
|
||||
&& !comments::select_grabs(ui, &pcx, view)
|
||||
&& !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view))
|
||||
&& !comments::select_grabs(&pcx, view)
|
||||
{
|
||||
crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
|
||||
crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view)
|
||||
} else {
|
||||
FieldPointer::Away
|
||||
};
|
||||
@@ -3556,11 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let (vx, vy) = xf.screen_to_view(p);
|
||||
text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]))
|
||||
};
|
||||
crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view)
|
||||
crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view)
|
||||
};
|
||||
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view);
|
||||
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view);
|
||||
let on_objects =
|
||||
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view);
|
||||
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view);
|
||||
let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify {
|
||||
let generation = doc.edit_generation();
|
||||
match view.edit_lines.get(&i) {
|
||||
@@ -3592,6 +3586,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
&added,
|
||||
@@ -3620,11 +3615,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// Editable text takes precedence over background/overlapping artwork. Both
|
||||
// inventories are painted every frame, even when text owns the pointer.
|
||||
let text_used = crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
|
||||
let text_used =
|
||||
crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
|
||||
let image_used = crate::edit_text_ui::image_input(
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
&images,
|
||||
@@ -3634,7 +3631,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
&& can_modify
|
||||
@@ -3712,23 +3709,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
} else if over_text && cursor_free {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
let press_here = ui
|
||||
.input(|inp| {
|
||||
inp.pointer.press_origin().or_else(|| {
|
||||
// A press and release can arrive in one pass; egui has already
|
||||
// cleared press_origin, but the press still starts multi-click selection.
|
||||
inp.events.iter().find_map(|event| match event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. }
|
||||
if pos.is_finite() =>
|
||||
{
|
||||
Some(*pos)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.is_some_and(|o| r.contains(o));
|
||||
let pressed = ui.input(|inp| inp.pointer.primary_pressed());
|
||||
// The pages' press, kept when its release came in the same frame (#2100): it
|
||||
// still starts multi-click selection.
|
||||
let press_here = ptr.origin.is_some_and(|o| r.contains(o));
|
||||
let pressed = ptr.pressed().is_some();
|
||||
if pressed && press_here && !over_link {
|
||||
let now = ui.input(|inp| inp.time);
|
||||
// The same limits egui uses to count clicks on release.
|
||||
@@ -3753,7 +3737,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// re-drag over text whose bar Copy closed would keep the bar hidden.
|
||||
view.actions_dismissed = None;
|
||||
view.selection_drag = true;
|
||||
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
let (ox, oy) = xf.screen_to_view(origin);
|
||||
if view.clicks < 2 {
|
||||
view.selection = if column {
|
||||
@@ -3843,11 +3827,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
crate::redact_ui::after_text(&resp, i, info, view);
|
||||
}
|
||||
if area_tool {
|
||||
crate::redact_ui::paint(ui, painter, i, view);
|
||||
crate::redact_ui::paint(painter, i, view, pointer);
|
||||
}
|
||||
comments::paint_page(ui, painter, &pcx, view);
|
||||
if editing_content {
|
||||
crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view);
|
||||
crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer);
|
||||
}
|
||||
if tool == QuickTool::EditText {
|
||||
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
|
||||
@@ -3856,21 +3840,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
crate::camera_ui::paint(painter, &xf, i, view);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view);
|
||||
crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer);
|
||||
}
|
||||
if preparing {
|
||||
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view);
|
||||
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer);
|
||||
if let Some(preview) = &table_preview {
|
||||
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
|
||||
}
|
||||
if calc_order {
|
||||
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
|
||||
if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) {
|
||||
if let Some(name) =
|
||||
crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer)
|
||||
{
|
||||
calc_hover = Some(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view);
|
||||
crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer);
|
||||
}
|
||||
|
||||
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
|
||||
@@ -3898,9 +3884,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback.
|
||||
if !page_covered
|
||||
&& !matches!(tool, QuickTool::Fill(_))
|
||||
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips
|
||||
// draw above the palette, so none shows while it is open, even beside it.
|
||||
if !matches!(tool, QuickTool::Fill(_))
|
||||
&& unobstructed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
for l in info.links.iter().filter(|l| l.page == i) {
|
||||
@@ -4022,7 +4009,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
&& navigation_enabled
|
||||
&& unobstructed
|
||||
&& !single_paging
|
||||
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
|
||||
&& let Some(p) = ptr.pos
|
||||
{
|
||||
let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt));
|
||||
if dy != 0.0 {
|
||||
@@ -4307,7 +4294,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.frame_visible = visible_now.iter().copied().collect();
|
||||
|
||||
if let Some((pos, text)) = hover_text {
|
||||
egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
|
||||
// Click-through, as a tooltip is: the pages lose the pointer under anything that isn't.
|
||||
let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false);
|
||||
tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_max_width(320.0);
|
||||
ui.label(text);
|
||||
@@ -4729,6 +4718,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not
|
||||
/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found
|
||||
/// the view under the pointer.
|
||||
fn zoom_anchor(ui: &egui::Ui) -> Option<Pos2> {
|
||||
ui.input(|i| i.pointer.hover_pos())
|
||||
}
|
||||
|
||||
/// The pointer and frame time that scroll the view while a comment is drawn. Not page input: it is
|
||||
/// read before the pages' widget exists, and the pointer may be past the view's edge.
|
||||
fn drawing_edge_pointer(ui: &egui::Ui) -> (Option<Pos2>, f32) {
|
||||
ui.input(|i| (i.pointer.latest_pos(), i.stable_dt))
|
||||
}
|
||||
|
||||
/// A press anywhere, or a key, stops the touchpad's glide (#531).
|
||||
fn glide_interrupted(ui: &egui::Ui) -> bool {
|
||||
ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })))
|
||||
}
|
||||
|
||||
/// Full screen's click on the document, which turns the page: a primary click this frame with the
|
||||
/// view on top under it in `area`. Not page input: it is read before the pages' widget exists, and
|
||||
/// the turn waits until the pages have had the click.
|
||||
fn presentation_click(ui: &egui::Ui, area: Rect) -> bool {
|
||||
ui.rect_contains_pointer(area) && ui.input(|i| i.pointer.primary_clicked())
|
||||
}
|
||||
|
||||
/// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855).
|
||||
fn in_size_hint_zone(area: Rect, p: Pos2) -> bool {
|
||||
p.x >= area.min.x && p.x < area.center().x && p.y > area.center().y && p.y <= area.max.y
|
||||
@@ -5291,7 +5305,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.in_reply_to.is_none() && comments::made_by(a, tool))
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation {
|
||||
@@ -5877,6 +5891,8 @@ mod tests {
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [0.0; 4],
|
||||
color: None,
|
||||
index: 0,
|
||||
|
||||
@@ -98,7 +98,7 @@ impl PdfCraftApp {
|
||||
pub fn make_props_default(&mut self, page: usize, index: usize, p: &CommentProps) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return };
|
||||
let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return };
|
||||
let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return };
|
||||
let mut style = self.comment_prefs.style(tool);
|
||||
if let Some(c) = p.color {
|
||||
@@ -167,12 +167,11 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
.and_then(|(_, id)| app.session.get(id))
|
||||
.zip(app.comment_props.as_ref())
|
||||
.map(|(doc, d)| {
|
||||
let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none());
|
||||
let nm = me.and_then(|a| a.name.clone());
|
||||
let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply());
|
||||
doc.info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|r| r.state.is_some() && nm.is_some() && r.in_reply_to == nm)
|
||||
.filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a)))
|
||||
.map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default()))
|
||||
.collect()
|
||||
})
|
||||
@@ -180,7 +179,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
let default_tool = app.comment_props.as_ref().and_then(|d| {
|
||||
let (_, id) = app.active_ids()?;
|
||||
let doc = app.session.get(id)?;
|
||||
let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none())?;
|
||||
let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?;
|
||||
crate::comments::tool_for_default(a, d.edited.endings.as_deref())
|
||||
});
|
||||
let Some(d) = app.comment_props.as_mut() else { return (false, true) };
|
||||
|
||||
@@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS
|
||||
use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{QuickTool, icons};
|
||||
|
||||
@@ -594,6 +595,8 @@ pub enum SortBy {
|
||||
pub(crate) struct PageCx<'a> {
|
||||
pub page: usize,
|
||||
pub xf: &'a PageXform,
|
||||
/// The pointer as the pages see it this frame.
|
||||
pub ptr: &'a PagePointer,
|
||||
pub info: &'a DocInfo,
|
||||
pub tool: QuickTool,
|
||||
pub prefs: &'a CommentPrefs,
|
||||
@@ -630,7 +633,7 @@ impl PageCx<'_> {
|
||||
|
||||
/// Top-level comments on this page, in paint order.
|
||||
fn comments(&self) -> impl Iterator<Item = &Annotation> {
|
||||
self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup")
|
||||
self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.is_comment() && a.subtype != "Popup")
|
||||
}
|
||||
|
||||
/// Where a comment is on screen: one rectangle, or one per marked line for text markup.
|
||||
@@ -808,13 +811,12 @@ pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio:
|
||||
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
|
||||
/// gesture belongs to commenting (text selection must ignore it).
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin);
|
||||
let page_rect = cx.xf.rect;
|
||||
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
|
||||
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
|
||||
let fill_grab = fill_grabs(ui, cx, view);
|
||||
let draw_grab = draw_grabs(ui, cx, view);
|
||||
let fill_grab = fill_grabs(cx, view);
|
||||
let draw_grab = draw_grabs(cx, view);
|
||||
let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None };
|
||||
let cv = &mut view.comments;
|
||||
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
@@ -1083,20 +1085,20 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While
|
||||
/// the button is down the press decides; otherwise the pointer (egui clears the press origin on
|
||||
/// release, so a click is located by the pointer).
|
||||
pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign)
|
||||
pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view, |a| a.fill_sign)
|
||||
}
|
||||
|
||||
/// With the Select tool, a comment that would move (not text markup) is picked up before a form
|
||||
/// field under it, so a signature placed over a field can still be dragged off it.
|
||||
pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype))
|
||||
pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
cx.tool == QuickTool::Select && grabs(cx, view, |a| !is_markup(&a.subtype))
|
||||
}
|
||||
|
||||
/// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles
|
||||
/// are picked up as with the Select tool, so the shape just drawn can be resized straight away
|
||||
/// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws.
|
||||
pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
pub(crate) fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool {
|
||||
let QuickTool::Comment(tool) = cx.tool else { return false };
|
||||
if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden {
|
||||
return false;
|
||||
@@ -1109,12 +1111,12 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool
|
||||
if cv.gesture.is_some() {
|
||||
return false;
|
||||
}
|
||||
press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true))
|
||||
press_point(cx).is_some_and(|p| on_handle(cx, view, p, |_| true))
|
||||
}
|
||||
|
||||
/// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the
|
||||
/// selected one's resize handles.
|
||||
fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
|
||||
fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
|
||||
if cx.hidden {
|
||||
return false;
|
||||
}
|
||||
@@ -1122,15 +1124,14 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(p) = press_point(ui, cx) else { return false };
|
||||
let Some(p) = press_point(cx) else { return false };
|
||||
on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick)
|
||||
}
|
||||
|
||||
/// Where a gesture on this page is decided: the press while the button is down, otherwise the
|
||||
/// pointer (egui clears the press origin on release). `None` off the page.
|
||||
fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option<Pos2> {
|
||||
let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down()));
|
||||
(if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p))
|
||||
fn press_point(cx: &PageCx<'_>) -> Option<Pos2> {
|
||||
(if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p))
|
||||
}
|
||||
|
||||
/// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.
|
||||
@@ -1320,7 +1321,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
|
||||
/// Paint comment selection, hover and gesture previews on a page.
|
||||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||||
let cv = &view.comments;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let pointer = cx.ptr.pos;
|
||||
// A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the
|
||||
// marked area reads before anything is applied.
|
||||
if cv.gesture.is_none()
|
||||
@@ -1471,7 +1472,7 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
|
||||
// The vertices so far, and a rubber band to the pointer.
|
||||
let mut line = pts.clone();
|
||||
if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) {
|
||||
if let Some(p) = cx.ptr.pos {
|
||||
line.push(p);
|
||||
}
|
||||
painter.add(egui::Shape::line(line, stroke));
|
||||
@@ -1582,7 +1583,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) };
|
||||
let cx = PageCx {
|
||||
page,
|
||||
xf: &xf,
|
||||
ptr: &PagePointer::default(),
|
||||
info,
|
||||
tool: QuickTool::Select,
|
||||
prefs,
|
||||
allowed: true,
|
||||
hidden: false,
|
||||
redact_mark: ([0.89, 0.13, 0.13], 1.0),
|
||||
};
|
||||
let anchor = cx.to_screen(c.at);
|
||||
let viewport = view.viewport_rect();
|
||||
let t = Tokens::get(ctx);
|
||||
@@ -1870,8 +1881,7 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo
|
||||
return;
|
||||
}
|
||||
let Some((page, index)) = cv.selected else { return };
|
||||
let movable =
|
||||
info.annotations.iter().any(|a| a.page == page && a.index == index && a.in_reply_to.is_none() && !is_markup(&a.subtype) && !a.locked);
|
||||
let movable = info.annotations.iter().any(|a| a.page == page && a.index == index && !a.is_reply() && !is_markup(&a.subtype) && !a.locked);
|
||||
let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return };
|
||||
if !movable {
|
||||
return;
|
||||
@@ -1934,7 +1944,7 @@ pub(crate) fn context_menu(
|
||||
can_modify: bool,
|
||||
) -> Option<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()));
|
||||
let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply()));
|
||||
match selected {
|
||||
Some(a) => {
|
||||
let (page, index) = (a.page, a.index);
|
||||
@@ -1979,7 +1989,7 @@ pub(crate) fn context_menu(
|
||||
}
|
||||
});
|
||||
});
|
||||
let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect();
|
||||
let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect();
|
||||
let marked = crate::comments_panel::is_marked(&thread);
|
||||
if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() {
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() })));
|
||||
@@ -2305,6 +2315,8 @@ mod tests {
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [40.0, 20.0, 70.0, 40.0],
|
||||
color: None,
|
||||
index: 0,
|
||||
@@ -2326,6 +2338,7 @@ mod tests {
|
||||
let cx = PageCx {
|
||||
page: 0,
|
||||
xf: &xf,
|
||||
ptr: &PagePointer::default(),
|
||||
info: &info,
|
||||
tool: QuickTool::Select,
|
||||
prefs: &prefs,
|
||||
@@ -2543,6 +2556,8 @@ mod tests {
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [10.0, 10.0, 50.0, 50.0],
|
||||
color: None,
|
||||
index: 0,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`).
|
||||
//!
|
||||
//! Header "Comments N" with search; an "Add a comment" box that posts a sticky note on the
|
||||
//! current page; cards grouped by page. Selecting a card selects the comment on the page and
|
||||
//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge.
|
||||
//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search;
|
||||
//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page.
|
||||
//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit,
|
||||
//! Set status, Delete). Status replies show as a badge.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -87,9 +88,16 @@ pub fn is_marked(thread: &[&Annotation]) -> bool {
|
||||
thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))
|
||||
}
|
||||
|
||||
/// The number shown in the header: comments and their replies (not status changes).
|
||||
/// The number shown in the header: comments, not their replies or status changes (as Acrobat
|
||||
/// counts them).
|
||||
pub fn count(info: &DocInfo) -> usize {
|
||||
info.annotations.iter().filter(|a| a.state.is_none()).count()
|
||||
info.annotations.iter().filter(|a| a.is_comment()).count()
|
||||
}
|
||||
|
||||
/// The replies under the header's count, at any depth: not status changes, checkmarks or a
|
||||
/// Replace Text caret (grouped with its strikeout, not a reply to it).
|
||||
pub fn reply_count(info: &DocInfo) -> usize {
|
||||
info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.grouped).count()
|
||||
}
|
||||
|
||||
fn matches(a: &Annotation, q: &str) -> bool {
|
||||
@@ -157,25 +165,32 @@ pub(crate) fn show(
|
||||
return edit;
|
||||
}
|
||||
let query = view.comments.search.clone().filter(|q| !q.trim().is_empty());
|
||||
let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new();
|
||||
let mut replies: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
let mut page_counts: HashMap<usize, usize> = HashMap::new();
|
||||
// Keep global name matching and source reply order. Page badges count roots before filtering.
|
||||
// Replies find their parent by reference (`/NM` need not be unique) and keep source order.
|
||||
// A reply at any depth joins the thread of the comment it starts from, as comment_list lists
|
||||
// it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it,
|
||||
// ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the
|
||||
// comment's, so the card's badge and checkmark leave it out (comment_list reports it on the
|
||||
// reply). Page badges count roots before filtering.
|
||||
let tops = pdfcraft_render::thread_tops(&info.annotations);
|
||||
for a in &info.annotations {
|
||||
if let Some(parent) = a.in_reply_to.as_deref() {
|
||||
replies.entry(parent).or_default().push(a);
|
||||
} else {
|
||||
if let Some(parent) = a.parent {
|
||||
let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent };
|
||||
replies.entry(key).or_default().push(a);
|
||||
} else if a.is_comment() {
|
||||
let count = page_counts.entry(a.page).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) };
|
||||
let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) };
|
||||
let cv = &view.comments;
|
||||
let status_of =
|
||||
|a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into());
|
||||
let mut roots: Vec<&Annotation> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|a| a.in_reply_to.is_none())
|
||||
.filter(|a| a.is_comment())
|
||||
.filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q))))
|
||||
.filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a)))
|
||||
.filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default()))
|
||||
@@ -304,8 +319,9 @@ fn card(
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let mut buttons_left = f32::INFINITY;
|
||||
let mut above = egui::Rect::NOTHING;
|
||||
let head = ui.horizontal(|ui| {
|
||||
avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
|
||||
above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
|
||||
let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto);
|
||||
ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text));
|
||||
if let Some(m) = &a.modified {
|
||||
@@ -376,11 +392,13 @@ fn card(
|
||||
});
|
||||
}
|
||||
});
|
||||
// Replies, joined to the parent by a thread line.
|
||||
// Replies, each joined to the avatar above it (the comment's or the previous reply's)
|
||||
// by a thread line that runs the whole gap.
|
||||
for r in &replies {
|
||||
ui.add_space(8.0);
|
||||
let resp = ui.horizontal(|ui| {
|
||||
avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint);
|
||||
let mut me = egui::Rect::NOTHING;
|
||||
ui.horizontal(|ui| {
|
||||
me = avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
@@ -393,8 +411,12 @@ fn card(
|
||||
widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false);
|
||||
});
|
||||
});
|
||||
let x = resp.response.rect.left() + 12.0;
|
||||
ui.painter().vline(x, (resp.response.rect.top() - 14.0)..=(resp.response.rect.top() - 2.0), Stroke::new(1.5, t.border));
|
||||
// The circles are 12 px in radius; leave a 1 px gap at each end.
|
||||
let (top, bottom) = (above.center().y + 13.0, me.center().y - 13.0);
|
||||
if above.is_positive() && top < bottom {
|
||||
ui.painter().vline(me.center().x, top..=bottom, Stroke::new(1.5, t.border));
|
||||
}
|
||||
above = me;
|
||||
}
|
||||
if selected && allowed {
|
||||
ui.add_space(8.0);
|
||||
@@ -501,8 +523,8 @@ fn card(
|
||||
edit
|
||||
}
|
||||
|
||||
/// A 26 pt round badge: a type glyph (comments) or initials (replies).
|
||||
fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) {
|
||||
/// A 26 pt round badge: a type glyph (comments) or initials (replies). Returns where it was drawn.
|
||||
fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover());
|
||||
let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) };
|
||||
ui.painter().circle_filled(r.center(), 12.0, bg);
|
||||
@@ -512,6 +534,7 @@ fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>,
|
||||
if let Some(s) = text {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, s, theme::semibold(10.5), t.text);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Darken very light annotation colours (e.g. note yellow) so their glyph stays legible.
|
||||
@@ -530,15 +553,13 @@ fn legible(c: Color32, t: &Tokens) -> Color32 {
|
||||
pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> {
|
||||
let mut command = None;
|
||||
let cv = &mut view.comments;
|
||||
let mut types: Vec<String> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| kind_label(a).to_string()).collect();
|
||||
let mut types: Vec<String> = info.annotations.iter().filter(|a| a.is_comment()).map(|a| kind_label(a).to_string()).collect();
|
||||
types.sort();
|
||||
types.dedup();
|
||||
let mut authors: Vec<String> =
|
||||
info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| a.author.clone().unwrap_or_default()).collect();
|
||||
let mut authors: Vec<String> = info.annotations.iter().filter(|a| a.is_comment()).map(|a| a.author.clone().unwrap_or_default()).collect();
|
||||
authors.sort();
|
||||
authors.dedup();
|
||||
let mut colors: Vec<(String, Option<[f32; 3]>)> =
|
||||
info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| (color_key(a), a.color)).collect();
|
||||
let mut colors: Vec<(String, Option<[f32; 3]>)> = info.annotations.iter().filter(|a| a.is_comment()).map(|a| (color_key(a), a.color)).collect();
|
||||
colors.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
colors.dedup_by(|a, b| a.0 == b.0);
|
||||
let active = !(cv.hidden_types.is_empty()
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
@@ -211,17 +212,18 @@ impl MoveBackground {
|
||||
}
|
||||
|
||||
/// The page without the moving item, then the item's pixels taken from `page_tex` (the page
|
||||
/// as rendered, item included where it was) at the dragged or applied position.
|
||||
/// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or
|
||||
/// applied position.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&self,
|
||||
ui: &egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
cv: &ContentView,
|
||||
page_tex: Option<egui::TextureId>,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
|
||||
if kp != page {
|
||||
@@ -229,7 +231,7 @@ impl MoveBackground {
|
||||
}
|
||||
let r = screen_rect(xf, info, page, from);
|
||||
let to = if let Some((_, grab, start)) = cv.grab {
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
|
||||
let Some(p) = pointer else { return };
|
||||
dragged(r, grab, p - start, self.picture)
|
||||
} else if let Some(to) = self.settling {
|
||||
let to = screen_rect(xf, info, page, to);
|
||||
@@ -315,14 +317,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
|
||||
}
|
||||
|
||||
/// Added text and images keep pointer ownership over a form field while editing content.
|
||||
pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
|
||||
pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
|
||||
let cv = &view.content;
|
||||
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
|
||||
return true;
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(p) = ptr.within(xf.rect) else { return false };
|
||||
on_page(added, page).into_iter().any(|(index, item)| {
|
||||
let rect = screen_rect(xf, info, page, item.content.rect());
|
||||
rect.expand(2.0).contains(p)
|
||||
@@ -337,6 +337,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
added: &[Added],
|
||||
@@ -346,13 +347,13 @@ pub(crate) fn page_input(
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let mut consumed = false;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
|
||||
let pointer = ptr.pos;
|
||||
let cv = &mut view.content;
|
||||
if let Some((gp, grab, start)) = cv.grab
|
||||
&& gp == page
|
||||
{
|
||||
consumed = true;
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
cv.grab = None;
|
||||
let end = pointer.unwrap_or(start);
|
||||
if let Some((sp, si)) = cv.selected
|
||||
@@ -421,7 +422,7 @@ pub(crate) fn page_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
|
||||
cv.grab = Some((page, Grab::Corner(left, top), origin));
|
||||
consumed = true;
|
||||
@@ -472,10 +473,18 @@ pub(crate) fn page_input(
|
||||
consumed || hit.is_some() && resp.is_pointer_button_down_on()
|
||||
}
|
||||
|
||||
/// Outlines of added items (hover and selection with handles) and the box being dragged.
|
||||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) {
|
||||
/// Outlines of added items (hover and selection with handles) and the box being dragged, the
|
||||
/// pages' `pointer` over them.
|
||||
pub(crate) fn paint_page(
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
added: &[Added],
|
||||
view: &DocView,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
let cv = &view.content;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
for (i, a) in on_page(added, page) {
|
||||
if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) {
|
||||
continue;
|
||||
|
||||
@@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
|
||||
/// A crop rectangle being dragged: (page, start on screen).
|
||||
pub type CropDrag = Option<(usize, Pos2)>;
|
||||
@@ -17,17 +18,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
|
||||
}
|
||||
|
||||
/// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
view: &mut DocView,
|
||||
allowed: bool,
|
||||
) -> bool {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid
|
||||
const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
|
||||
pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
// The page sets hover cursors from the raw pointer position (form fields, links, comments)
|
||||
// before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from
|
||||
// the arrow; the modal's own widgets set theirs as they are drawn.
|
||||
if app.modal_open() || ctx.memory(|m| m.top_modal_layer().is_some()) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Default);
|
||||
}
|
||||
dialogs(app, ctx);
|
||||
// Last, so it sits above Document Properties when Security's Unlock… asked for it.
|
||||
unlock(app, ctx);
|
||||
@@ -43,6 +49,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
|
||||
app.session.refresh_fonts(id);
|
||||
}
|
||||
// Properties ▸ Space Audit walks the whole file, so it is measured once per edit, not per frame.
|
||||
if dialog == Dialog::Properties(PropsTab::SpaceAudit) {
|
||||
app.refresh_space_audit();
|
||||
}
|
||||
// Seed the editable Description fields from the document when the dialog opens.
|
||||
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
|
||||
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
|
||||
@@ -118,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
(PropsTab::Fonts, tl!("Fonts")),
|
||||
(PropsTab::Custom, tl!("Custom")),
|
||||
(PropsTab::Advanced, tl!("Advanced")),
|
||||
(PropsTab::SpaceAudit, tl!("Space Audit")),
|
||||
] {
|
||||
if widgets::mode_tab(ui, label, tab == tb).clicked() {
|
||||
next = Dialog::Properties(tb);
|
||||
@@ -129,11 +140,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let Some((_, id)) = app.active_ids() else { return };
|
||||
let Some(doc) = app.session.get(id) else { return };
|
||||
let i = &doc.info;
|
||||
// Description shows plain values among text fields; they start where the fields' text does.
|
||||
let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some();
|
||||
let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 };
|
||||
let row = |ui: &mut egui::Ui, k: &str, v: String| {
|
||||
// Row labels are UI chrome; values are document data and stay as they are.
|
||||
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_width(420.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(inset);
|
||||
ui.add(
|
||||
egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(),
|
||||
);
|
||||
@@ -141,15 +155,23 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
};
|
||||
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
if tab == PropsTab::SpaceAudit {
|
||||
ui.add_space(4.0);
|
||||
crate::optimize_ui::audit_table(ui, &app.space_audit, &t);
|
||||
return;
|
||||
}
|
||||
egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
|
||||
PropsTab::Description => {
|
||||
row(ui, "File", crate::bidi::visual(&doc.name).into_owned());
|
||||
match app.props_draft.as_mut() {
|
||||
Some((_, draft)) if doc.allows_modification() => {
|
||||
Some((_, draft)) if editable_info => {
|
||||
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
|
||||
let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
|
||||
ui.add(widgets::line(v).desired_width(420.0).background_color(t.field).id_salt(("info", *k)))
|
||||
.labelled_by(l.id);
|
||||
// Subject and Keywords are often a sentence or a list: they wrap and grow
|
||||
// instead of hiding most of the value past the field's edge.
|
||||
let field =
|
||||
if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) };
|
||||
ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
@@ -500,7 +522,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
})
|
||||
// Laid out above, outside the two-column grid.
|
||||
PropsTab::SpaceAudit => {}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Split => {
|
||||
@@ -837,7 +861,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
optimize_now = ok;
|
||||
close = ok || cancel;
|
||||
if std::mem::take(&mut app.optimize_draft.audit) {
|
||||
app.space_audit = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.audit_space()).unwrap_or_default();
|
||||
app.refresh_space_audit();
|
||||
next = Dialog::AuditSpace;
|
||||
}
|
||||
return;
|
||||
@@ -1309,43 +1333,47 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if dialog == Dialog::Recovery {
|
||||
if widgets::pill_button(ui, tl!("Recover"), true).clicked() {
|
||||
recover = Some(true);
|
||||
// One row high: on its own, a right-to-left layout centres the buttons in all the height
|
||||
// left, which after a taller Properties tab is the room that tab left behind.
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if dialog == Dialog::Recovery {
|
||||
if widgets::pill_button(ui, tl!("Recover"), true).clicked() {
|
||||
recover = Some(true);
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Discard"), false).clicked() {
|
||||
recover = Some(false);
|
||||
close = true;
|
||||
}
|
||||
} else if dialog == Dialog::NumberPages {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
apply_number = true;
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if dialog == Dialog::Split {
|
||||
if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() {
|
||||
split_now = split_ready.clone();
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if changed {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
apply = true;
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if widgets::pill_button(ui, tl!("Close"), true).clicked() {
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Discard"), false).clicked() {
|
||||
recover = Some(false);
|
||||
close = true;
|
||||
}
|
||||
} else if dialog == Dialog::NumberPages {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
apply_number = true;
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if dialog == Dialog::Split {
|
||||
if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() {
|
||||
split_now = split_ready.clone();
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if changed {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
apply = true;
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if widgets::pill_button(ui, tl!("Close"), true).clicked() {
|
||||
close = true;
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
if let Some(yes) = recover {
|
||||
|
||||
@@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
|
||||
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
|
||||
@@ -245,6 +246,9 @@ pub struct LineEditor {
|
||||
size: f32,
|
||||
/// Take the keyboard focus on the next frame.
|
||||
pub(crate) focus: bool,
|
||||
/// Whether the text box had the keyboard at the end of the editor's last frame
|
||||
/// ([`lost_focus_once`]).
|
||||
had_focus: bool,
|
||||
/// The Format text panel's values, and what the paragraph had (to send only changes).
|
||||
pub look: pdfcraft_engine::AddedText,
|
||||
look0: pdfcraft_engine::AddedText,
|
||||
@@ -557,6 +561,7 @@ fn crop_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
view: &mut DocView,
|
||||
@@ -566,12 +571,12 @@ fn crop_input(
|
||||
) -> bool {
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ui.input(|inp| inp.pointer.hover_pos());
|
||||
let hover_pos = ptr.pos;
|
||||
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
|
||||
// A drag in progress moves a grip; the box stays inside the picture and at least 6 px.
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = ui.input(|inp| inp.pointer.press_origin())
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let k = keep_screen(mode.keep);
|
||||
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8));
|
||||
@@ -618,7 +623,7 @@ fn crop_input(
|
||||
max.x = max.x.clamp(min.x + min_w, b.max.x);
|
||||
max.y = max.y.clamp(min.y + min_h, b.max.y);
|
||||
mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max));
|
||||
if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
mode.drag = None;
|
||||
}
|
||||
}
|
||||
@@ -724,7 +729,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
|
||||
|
||||
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
|
||||
/// applies after the pointer leaves the image/page while dragging.
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
|
||||
if selection.drag.is_some() {
|
||||
return true;
|
||||
@@ -732,7 +737,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf
|
||||
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
|
||||
let Some(rect) = image.visible_rect else { return false };
|
||||
let b = xf.user_rect(info, page, rect.map(|v| v as f32));
|
||||
let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() });
|
||||
let p = if ptr.primary_down { ptr.origin } else { ptr.pos };
|
||||
let knob = rotate_knob(b, ui.clip_rect()).0;
|
||||
p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0))
|
||||
}
|
||||
@@ -860,6 +865,7 @@ pub(crate) fn image_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
images: &[pdfcraft_engine::PageImage],
|
||||
@@ -869,7 +875,7 @@ pub(crate) fn image_input(
|
||||
) -> bool {
|
||||
let boxes: Vec<Rect> = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect();
|
||||
let painter = ui.painter();
|
||||
let hover_pos = ui.input(|i| i.pointer.hover_pos());
|
||||
let hover_pos = ptr.pos;
|
||||
let image_contains = |index: usize, point: Pos2| {
|
||||
let (x, y) = xf.screen_to_view(point);
|
||||
let Some(p) = info.pages.get(page) else { return false };
|
||||
@@ -913,7 +919,7 @@ pub(crate) fn image_input(
|
||||
if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) {
|
||||
let b = boxes[i];
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) {
|
||||
return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction);
|
||||
return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction);
|
||||
}
|
||||
let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()];
|
||||
let handles = |rect: Rect| {
|
||||
@@ -963,10 +969,9 @@ pub(crate) fn image_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = origin
|
||||
&& let Some(o) = ptr.origin
|
||||
&& o.distance(knob) < 8.0
|
||||
{
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
@@ -975,7 +980,7 @@ pub(crate) fn image_input(
|
||||
}
|
||||
} else if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = origin
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
|
||||
if corner.is_some() || image_contains(i, o) {
|
||||
@@ -1002,7 +1007,7 @@ pub(crate) fn image_input(
|
||||
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
|
||||
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
s.turning = false;
|
||||
@@ -1060,7 +1065,7 @@ pub(crate) fn image_input(
|
||||
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
handles(preview);
|
||||
}
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
let norm = |r: Rect| {
|
||||
@@ -1254,6 +1259,7 @@ fn open_editor(
|
||||
max_width,
|
||||
size: (l.size as f32 * scale).clamp(8.0, 72.0),
|
||||
focus: true,
|
||||
had_focus: false,
|
||||
look: look_of(l),
|
||||
look0: look_of(l),
|
||||
family_chosen: false,
|
||||
@@ -1270,6 +1276,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
lines: &[pdfcraft_engine::TextBlock],
|
||||
@@ -1317,7 +1324,7 @@ pub(crate) fn page_input(
|
||||
if let Some(d) = view.block_drag.filter(|d| d.page == page)
|
||||
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
|
||||
{
|
||||
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
|
||||
let p = ptr.pos.unwrap_or(d.start);
|
||||
let dx = p.x - d.start.x;
|
||||
let preview = match d.grip {
|
||||
Grip::Move => b.translate(p - d.start),
|
||||
@@ -1330,7 +1337,7 @@ pub(crate) fn page_input(
|
||||
draw_handles(preview);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
|
||||
if resp.drag_stopped() || !ptr.any_down {
|
||||
view.block_drag = None;
|
||||
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
|
||||
let edit = if resizing {
|
||||
@@ -1362,13 +1369,13 @@ pub(crate) fn page_input(
|
||||
// quick flick has left the edge by then.
|
||||
if resp.drag_started()
|
||||
&& active.is_none()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(o) = ptr.origin
|
||||
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
|
||||
{
|
||||
view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip });
|
||||
return true;
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let Some(p) = ptr.within(xf.rect) else { return false };
|
||||
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
|
||||
if active != Some(hit) {
|
||||
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
@@ -1629,9 +1636,8 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
|
||||
/// The inline editor; returns the edit once the text is applied.
|
||||
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
|
||||
let view_id = view.id;
|
||||
// Clicks outside the document (the Format text panel) keep the paragraph open.
|
||||
// Where the click that took the focus was: the pointer itself may be gone by then (a touch, a
|
||||
// pen lifted away).
|
||||
// Clicks outside the document (the Format text panel) keep the paragraph open. Where the
|
||||
// click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts.
|
||||
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
|
||||
let page = view.line_editor.as_ref()?.page;
|
||||
let xf = view.page_xform(page)?;
|
||||
@@ -1785,11 +1791,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
}
|
||||
ed.focus = false;
|
||||
}
|
||||
let lost = lost_focus_once(&r, &mut ed.had_focus);
|
||||
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
|
||||
if esc {
|
||||
done = Some(false);
|
||||
} else if apply || (r.lost_focus() && !outside && !hold) {
|
||||
} else if apply || (lost && !outside && !hold) {
|
||||
done = Some(true);
|
||||
}
|
||||
});
|
||||
@@ -1824,6 +1831,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option<Edit> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at
|
||||
/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second
|
||||
/// frame as well, and an editor that judges a click away by where the click landed must judge it
|
||||
/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends
|
||||
/// `PointerGone` with every touch release) and a mouse may have moved on.
|
||||
pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool {
|
||||
let has = r.has_focus();
|
||||
std::mem::replace(had, has) && !has
|
||||
}
|
||||
|
||||
/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones.
|
||||
pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
|
||||
let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b());
|
||||
@@ -1881,6 +1898,7 @@ mod tests {
|
||||
max_width: 400.0,
|
||||
size: block.size as f32,
|
||||
focus: true,
|
||||
had_focus: false,
|
||||
look: look_of(&block),
|
||||
look0: look_of(&block),
|
||||
family_chosen: false,
|
||||
|
||||
@@ -38,6 +38,11 @@ pub enum SaveTarget {
|
||||
As,
|
||||
}
|
||||
|
||||
/// Text typed into a new text box that isn't added to the page yet.
|
||||
fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty())
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Apply an edit to the active document. Returns `true` on success; failures are shown.
|
||||
pub fn apply_edit(&mut self, edit: Edit) -> bool {
|
||||
@@ -155,7 +160,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
Edit::AddAnnotation(a) => {
|
||||
// Select the new comment (appended last among the page's comments).
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max();
|
||||
view.comments.selected = newest.map(|n| (a.page, n));
|
||||
view.comments.reveal = true;
|
||||
// A new highlight opens its note for typing straight away, as in Acrobat,
|
||||
@@ -613,7 +618,7 @@ impl PdfCraftApp {
|
||||
let newest = self
|
||||
.session
|
||||
.get(view.id)
|
||||
.and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && a.in_reply_to.is_none()).map(|a| a.index).max());
|
||||
.and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max());
|
||||
view.comments.selected = newest.map(|index| (page, index));
|
||||
view.comments.reveal = true;
|
||||
self.quick_tool = crate::QuickTool::Select;
|
||||
@@ -745,6 +750,7 @@ impl PdfCraftApp {
|
||||
|| v.content.blocked().is_some()
|
||||
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|
||||
|| v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())
|
||||
|| new_text_typed(v)
|
||||
|| v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text())
|
||||
}
|
||||
|
||||
@@ -1778,6 +1784,7 @@ mod revert_draft_tests {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,6 +1870,74 @@ mod revert_draft_tests {
|
||||
assert!(app.unsaved_flag.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
/// Text typed into a new text box is lost on close unless the save prompt appears; an empty
|
||||
/// box has nothing to lose.
|
||||
#[test]
|
||||
fn text_typed_into_a_new_text_box_counts_as_unsaved_work() {
|
||||
use crate::content_ui::{TextDraft, default_style};
|
||||
let mut app = app();
|
||||
let draft = |text: &str| TextDraft {
|
||||
page: 0,
|
||||
index: None,
|
||||
rect: [0.0; 4],
|
||||
text: text.into(),
|
||||
style: default_style(),
|
||||
focus: true,
|
||||
selection: None,
|
||||
typing_from: None,
|
||||
};
|
||||
assert!(!app.has_unsaved_work(0));
|
||||
app.views[0].content.draft = Some(draft(" \n"));
|
||||
assert!(!app.has_unsaved_work(0));
|
||||
app.views[0].content.draft = Some(draft("Typed"));
|
||||
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
|
||||
assert!(app.has_unsaved_work(0));
|
||||
}
|
||||
|
||||
/// Close ▸ Save, and Quit ▸ Save, write the text typed into a new text box before the tab
|
||||
/// closes.
|
||||
#[test]
|
||||
fn saving_on_close_keeps_text_typed_into_a_new_text_box() {
|
||||
use crate::content_ui::{TextDraft, default_style};
|
||||
use pdfcraft_engine::AddedContent;
|
||||
for quit in [false, true] {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-new-text-{}-{quit}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("source.pdf").to_string_lossy().into_owned();
|
||||
let bytes = include_bytes!("../tests/data/form.pdf").to_vec();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap();
|
||||
let rect = [72.0, 72.0, 272.0, 92.0];
|
||||
app.views[0].content.draft = Some(TextDraft {
|
||||
page: 0,
|
||||
index: None,
|
||||
rect,
|
||||
text: "Typed".into(),
|
||||
style: default_style(),
|
||||
focus: true,
|
||||
selection: None,
|
||||
typing_from: None,
|
||||
});
|
||||
let ctx = egui::Context::default();
|
||||
if quit {
|
||||
assert!(!app.request_quit(&ctx));
|
||||
} else {
|
||||
app.request_close_tab(0);
|
||||
}
|
||||
assert!(app.close_request.is_some());
|
||||
app.resolve_close(&ctx, Some(true));
|
||||
assert!(app.views.is_empty(), "the tab closes once saved");
|
||||
|
||||
let mut reopened = PdfCraftApp::new();
|
||||
reopened.open_bytes("source.pdf", Some(path.clone()), std::fs::read(&path).unwrap()).unwrap();
|
||||
let doc = reopened.session.get(reopened.views[0].id).unwrap();
|
||||
assert!(doc.added().iter().any(|a| matches!(&a.content, AddedContent::Text(t) if t.text == "Typed")), "quit: {quit}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
|
||||
for queued in [false, true] {
|
||||
|
||||
@@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature
|
||||
use pdfcraft_render::{DocInfo, PageInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -626,6 +627,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
tool: FillTool,
|
||||
@@ -637,10 +639,7 @@ pub(crate) fn page_input(
|
||||
author: &str,
|
||||
date_text: &Result<String, String>,
|
||||
) -> Option<FillAction> {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos())?;
|
||||
if !resp.contains_pointer() || !xf.rect.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
let pointer = ptr.within(xf.rect)?;
|
||||
let p = info.pages.get(page)?;
|
||||
let at = to_user(xf, info, page, pointer);
|
||||
let saved = match tool {
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2};
|
||||
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
@@ -42,6 +43,9 @@ pub struct Focus {
|
||||
/// The text the calendar last saw, so it moves to a typed date once, when the typing becomes
|
||||
/// a whole date, and otherwise keeps the month its arrows turned to.
|
||||
pub calendar_text: Option<String>,
|
||||
/// Whether the text box had the keyboard at the end of the editor's last frame
|
||||
/// ([`crate::edit_text_ui::lost_focus_once`]).
|
||||
pub(crate) had_focus: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -120,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,14 +170,14 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
form: &[FormField],
|
||||
allowed: bool,
|
||||
view: &mut DocView,
|
||||
) -> FieldPointer {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away };
|
||||
let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away };
|
||||
let hit = form.iter().find_map(|f| {
|
||||
f.widgets
|
||||
.iter()
|
||||
@@ -202,7 +207,7 @@ pub(crate) fn page_input(
|
||||
}
|
||||
// A text field opens on the press rather than the click, so a drag that starts on it
|
||||
// selects its text (#2213).
|
||||
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed());
|
||||
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some();
|
||||
if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
|
||||
commit(view, form);
|
||||
view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) });
|
||||
@@ -234,7 +239,7 @@ pub(crate) fn page_input(
|
||||
// The press already committed the previous field and opened this one (above), unless
|
||||
// press and release came in one frame. Committing again would re-run a validation that
|
||||
// refused the value on the press.
|
||||
FormFieldKind::Text if !ui.input(|i| i.pointer.primary_pressed()) => {
|
||||
FormFieldKind::Text if ptr.pressed().is_none() => {
|
||||
// An editor opened by the press cannot be hit until a frame has laid it out: the
|
||||
// release lands on the page and would take its focus away.
|
||||
if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) {
|
||||
@@ -475,9 +480,16 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
|
||||
picked
|
||||
}
|
||||
|
||||
/// Paint focus and hover frames on a page.
|
||||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
/// Paint focus and hover frames on a page, the pages' `pointer` over it.
|
||||
pub(crate) fn paint_page(
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
form: &[FormField],
|
||||
view: &DocView,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
for f in form {
|
||||
for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) {
|
||||
let r = widget_rect(xf, info, page, w.rect);
|
||||
@@ -644,6 +656,10 @@ pub(crate) fn overlay(
|
||||
return None;
|
||||
};
|
||||
let rect = widget_rect(&xf, info, page, w.rect);
|
||||
// Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a
|
||||
// tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and
|
||||
// winit reports Windows pen input as touch), which clears `hover_pos`.
|
||||
let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
|
||||
let zoom = xf.zoom();
|
||||
let t = Tokens::get(ctx);
|
||||
let mut edit = None;
|
||||
@@ -730,6 +746,7 @@ pub(crate) fn overlay(
|
||||
st.store(ui.ctx(), r.id);
|
||||
}
|
||||
}
|
||||
let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus);
|
||||
let (enter, esc, tab, shift) = ui.input(|i| {
|
||||
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)
|
||||
});
|
||||
@@ -737,7 +754,7 @@ pub(crate) fn overlay(
|
||||
close = true;
|
||||
} else if tab {
|
||||
next = Some(!shift);
|
||||
} else if r.lost_focus()
|
||||
} else if lost
|
||||
&& (!multiline || !enter)
|
||||
&& !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
|
||||
{
|
||||
@@ -785,9 +802,7 @@ pub(crate) fn overlay(
|
||||
});
|
||||
});
|
||||
});
|
||||
let clicked_away = ctx.input(|i| i.pointer.any_click())
|
||||
&& !resp.response.contains_pointer()
|
||||
&& !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
|
||||
let clicked_away = !resp.response.contains_pointer() && clicked_outside();
|
||||
let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift));
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
@@ -821,8 +836,7 @@ pub(crate) fn overlay(
|
||||
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
|
||||
}
|
||||
}
|
||||
let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
|
||||
if clicked_away {
|
||||
if clicked_outside() {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
@@ -859,8 +873,7 @@ pub(crate) fn overlay(
|
||||
None
|
||||
}
|
||||
});
|
||||
let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p)));
|
||||
if esc || clicked_away {
|
||||
if esc || clicked_outside() {
|
||||
close = true;
|
||||
} else if tab {
|
||||
next = Some(!shift);
|
||||
|
||||
+27
-19
@@ -10,7 +10,7 @@ use crate::{PdfCraftApp, icons, panels::human_size, widgets};
|
||||
const RECOMMENDED: [&str; 5] = ["organize", "comment", "form", "edit", "protect"];
|
||||
|
||||
/// A soft shadow that rises on hover and settles when the card is pressed.
|
||||
fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) {
|
||||
pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) {
|
||||
let lift = press.hover * (1.0 - press.down);
|
||||
if lift > 0.04 {
|
||||
let shadow = egui::Shadow { offset: [0, 3], blur: (14.0 * lift) as u8, spread: 0, color: t.page_shadow.gamma_multiply(0.85 * lift) };
|
||||
@@ -29,26 +29,34 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
.font(theme::regular(14.0)),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
widgets::artcraft_mark(ui, 28.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0)));
|
||||
ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted));
|
||||
if !app.community_card_closed {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
widgets::artcraft_mark(ui, 28.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0)));
|
||||
ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted));
|
||||
});
|
||||
// Closed for this run only: the card is back on the next launch.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if icons::button(ui, "x", 24.0, false, tl!("Close")).clicked() {
|
||||
app.community_card_closed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if let Some(cmd) = widgets::community_links(ui) {
|
||||
app.execute(cmd);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if let Some(cmd) = widgets::community_links(ui) {
|
||||
app.execute(cmd);
|
||||
}
|
||||
});
|
||||
ui.add_space(22.0);
|
||||
ui.add_space(22.0);
|
||||
}
|
||||
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
|
||||
@@ -2766,6 +2766,14 @@ certificate Version الإصدار
|
||||
Make properties default جعل الخصائص افتراضية
|
||||
Pages selected الصفحات المحددة
|
||||
Page properties خصائص الصفحة
|
||||
Create & Edit الإنشاء والتحرير
|
||||
Review & Compare المراجعة والمقارنة
|
||||
Forms & Signatures النماذج والتوقيعات
|
||||
Protect & Standardize الحماية والتوحيد القياسي
|
||||
Customize & Automate التخصيص والأتمتة
|
||||
No tools match your search. لا توجد أدوات تطابق بحثك.
|
||||
Expand all tools توسيع كل الأدوات
|
||||
Collapse all tools طي كل الأدوات
|
||||
Attach a file… إرفاق ملف…
|
||||
Delete attachment حذف المرفق
|
||||
Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد
|
||||
@@ -2807,3 +2815,4 @@ certificate Version الإصدار
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today اليوم
|
||||
@plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2452,6 +2452,14 @@ animation effects Off Изключени
|
||||
Make properties default Задаване като свойства по подразбиране
|
||||
Pages selected Избрани страници
|
||||
Page properties Свойства на страницата
|
||||
Create & Edit Създаване и редактиране
|
||||
Review & Compare Преглед и сравнение
|
||||
Forms & Signatures Формуляри и подписи
|
||||
Protect & Standardize Защита и стандартизация
|
||||
Customize & Automate Персонализиране и автоматизация
|
||||
No tools match your search. Няма инструменти, отговарящи на търсенето.
|
||||
Expand all tools Разгъване на всички инструменти
|
||||
Collapse all tools Свиване на всички инструменти
|
||||
Attach a file… Прикачване на файл…
|
||||
Delete attachment Изтриване на прикачения файл
|
||||
Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията
|
||||
@@ -2494,3 +2502,4 @@ animation effects Off Изключени
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Днес
|
||||
@plural {n} reply|{n} replies {n} отговор|{n} отговора
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2654,6 +2654,14 @@ certificate SHA-256 digest Resum SHA-256
|
||||
Optimizing… merging image fragments S'està optimitzant… fusionant fragments d'imatge
|
||||
Outline colour: Color del contorn:
|
||||
Page properties Propietats de la pàgina
|
||||
Create & Edit Crea i edita
|
||||
Review & Compare Revisa i compara
|
||||
Forms & Signatures Formularis i signatures
|
||||
Protect & Standardize Protegeix i estandarditza
|
||||
Customize & Automate Personalitza i automatitza
|
||||
No tools match your search. Cap eina coincideix amb la cerca.
|
||||
Expand all tools Desplega totes les eines
|
||||
Collapse all tools Replega totes les eines
|
||||
Pages counted Pàgines comptades
|
||||
Pages selected Pàgines seleccionades
|
||||
Print as image Imprimeix com a imatge
|
||||
@@ -2792,3 +2800,4 @@ theme Appearance Aspecte
|
||||
Attaching files is not available on the web yet Encara no es poden adjuntar fitxers al web
|
||||
Delete attachment Elimina l'adjunt
|
||||
Delete attachment {key} Elimina l'adjunt {key}
|
||||
@plural {n} reply|{n} replies {n} resposta|{n} respostes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 3.
|
@@ -2720,6 +2720,15 @@ certificate Version وەشان
|
||||
Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی
|
||||
Pages selected پەڕە هەڵبژێردراوەکان
|
||||
Page properties تایبەتمەندییەکانی پەڕە
|
||||
Create & Edit دروستکردن و دەستکاریکردن
|
||||
Review & Compare پێداچوونەوە و بەراوردکردن
|
||||
Forms & Signatures فۆڕمەکان و واژووەکان
|
||||
Protect & Standardize پاراستن و ستانداردکردن
|
||||
Customize & Automate تایبەتکردن و خۆکارکردن
|
||||
No tools match your search. هیچ ئامرازێک لەگەڵ گەڕانەکەت ناگونجێت.
|
||||
Expand all tools فراوانکردنی هەموو ئامرازەکان
|
||||
Collapse all tools کۆکردنەوەی هەموو ئامرازەکان
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ئەمڕۆ
|
||||
@plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2563,6 +2563,14 @@ animation effects Off Vypnuto
|
||||
Make properties default Nastavit vlastnosti jako výchozí
|
||||
Pages selected Vybrané stránky
|
||||
Page properties Vlastnosti stránky
|
||||
Create & Edit Vytváření a úpravy
|
||||
Review & Compare Revize a porovnání
|
||||
Forms & Signatures Formuláře a podpisy
|
||||
Protect & Standardize Ochrana a standardizace
|
||||
Customize & Automate Přizpůsobení a automatizace
|
||||
No tools match your search. Hledání neodpovídá žádný nástroj.
|
||||
Expand all tools Rozbalit všechny nástroje
|
||||
Collapse all tools Sbalit všechny nástroje
|
||||
Attach a file… Připojit soubor…
|
||||
Delete attachment Odstranit přílohu
|
||||
Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici
|
||||
@@ -2838,3 +2846,4 @@ certificate Version Verze
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Dnes
|
||||
@plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2490,6 +2490,14 @@ animation effects Off Aus
|
||||
Make properties default Eigenschaften als Standard festlegen
|
||||
Pages selected Ausgewählte Seiten
|
||||
Page properties Seiteneigenschaften
|
||||
Create & Edit Erstellen & Bearbeiten
|
||||
Review & Compare Prüfen & Vergleichen
|
||||
Forms & Signatures Formulare & Signaturen
|
||||
Protect & Standardize Schützen & Standardisieren
|
||||
Customize & Automate Anpassen & Automatisieren
|
||||
No tools match your search. Keine Werkzeuge entsprechen Ihrer Suche.
|
||||
Expand all tools Alle Werkzeuge ausklappen
|
||||
Collapse all tools Alle Werkzeuge einklappen
|
||||
Attach a file… Datei anhängen…
|
||||
Delete attachment Anhang löschen
|
||||
Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar
|
||||
@@ -2532,3 +2540,4 @@ animation effects Off Aus
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Heute
|
||||
@plural {n} reply|{n} replies {n} Antwort|{n} Antworten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2495,6 +2495,14 @@ animation effects Off Ανενεργά
|
||||
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
|
||||
Pages selected Επιλεγμένες σελίδες
|
||||
Page properties Ιδιότητες σελίδας
|
||||
Create & Edit Δημιουργία και επεξεργασία
|
||||
Review & Compare Αναθεώρηση και σύγκριση
|
||||
Forms & Signatures Φόρμες και υπογραφές
|
||||
Protect & Standardize Προστασία και τυποποίηση
|
||||
Customize & Automate Προσαρμογή και αυτοματοποίηση
|
||||
No tools match your search. Κανένα εργαλείο δεν ταιριάζει με την αναζήτησή σας.
|
||||
Expand all tools Ανάπτυξη όλων των εργαλείων
|
||||
Collapse all tools Σύμπτυξη όλων των εργαλείων
|
||||
Attach a file… Επισύναψη αρχείου…
|
||||
Delete attachment Διαγραφή συνημμένου
|
||||
Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό
|
||||
@@ -2537,3 +2545,4 @@ animation effects Off Ανενεργά
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Σήμερα
|
||||
@plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 482 and column 9.
|
@@ -2632,6 +2632,14 @@ animation effects Off Desactivados
|
||||
Make properties default Usar como propiedades predeterminadas
|
||||
Pages selected Páginas seleccionadas
|
||||
Page properties Propiedades de página
|
||||
Create & Edit Crear y editar
|
||||
Review & Compare Revisar y comparar
|
||||
Forms & Signatures Formularios y firmas
|
||||
Protect & Standardize Proteger y estandarizar
|
||||
Customize & Automate Personalizar y automatizar
|
||||
No tools match your search. Ninguna herramienta coincide con la búsqueda.
|
||||
Expand all tools Expandir todas las herramientas
|
||||
Collapse all tools Contraer todas las herramientas
|
||||
Font Fuente
|
||||
Bulleted list Lista con viñetas
|
||||
Numbered list Lista numerada
|
||||
@@ -2854,3 +2862,4 @@ theme Appearance Aspecto
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hoy
|
||||
@plural {n} reply|{n} replies {n} respuesta|{n} respuestas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2698,6 +2698,14 @@ certificate SHA-256 digest SHA-256-tiiviste
|
||||
Make properties default Aseta ominaisuudet oletuksiksi
|
||||
Pages selected Valitut sivut
|
||||
Page properties Sivun ominaisuudet
|
||||
Create & Edit Luo ja muokkaa
|
||||
Review & Compare Tarkasta ja vertaa
|
||||
Forms & Signatures Lomakkeet ja allekirjoitukset
|
||||
Protect & Standardize Suojaa ja standardoi
|
||||
Customize & Automate Mukauta ja automatisoi
|
||||
No tools match your search. Hakuasi vastaavia työkaluja ei löytynyt.
|
||||
Expand all tools Laajenna kaikki työkalut
|
||||
Collapse all tools Kutista kaikki työkalut
|
||||
The password must have at least 8 characters. Salasanassa on oltava vähintään 8 merkkiä.
|
||||
theme Appearance Teema
|
||||
# Labels added upstream 2026-10-16…18.
|
||||
@@ -2801,3 +2809,4 @@ theme Appearance Teema
|
||||
Attaching files is not available on the web yet Tiedostojen liittäminen ei ole vielä käytettävissä verkossa
|
||||
Delete attachment Poista liite
|
||||
Delete attachment {key} Poista liite {key}
|
||||
@plural {n} reply|{n} replies {n} vastaus|{n} vastausta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 47.
|
@@ -2489,6 +2489,14 @@ animation effects Off Désactivés
|
||||
Make properties default Définir comme propriétés par défaut
|
||||
Pages selected Pages sélectionnées
|
||||
Page properties Propriétés de la page
|
||||
Create & Edit Créer et modifier
|
||||
Review & Compare Réviser et comparer
|
||||
Forms & Signatures Formulaires et signatures
|
||||
Protect & Standardize Protéger et normaliser
|
||||
Customize & Automate Personnaliser et automatiser
|
||||
No tools match your search. Aucun outil ne correspond à votre recherche.
|
||||
Expand all tools Développer tous les outils
|
||||
Collapse all tools Réduire tous les outils
|
||||
Attach a file… Joindre un fichier…
|
||||
Delete attachment Supprimer la pièce jointe
|
||||
Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web
|
||||
@@ -2797,3 +2805,4 @@ certificate Version Version
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Aujourd'hui
|
||||
@plural {n} reply|{n} replies {n} réponse|{n} réponses
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2682,6 +2682,14 @@ certificate SHA-256 digest SHA-256 डाइजेस्ट
|
||||
Make properties default गुण डिफ़ॉल्ट बनाएँ
|
||||
Pages selected चयनित पृष्ठ
|
||||
Page properties पृष्ठ गुण
|
||||
Create & Edit बनाएँ और संपादित करें
|
||||
Review & Compare समीक्षा और तुलना करें
|
||||
Forms & Signatures फ़ॉर्म और हस्ताक्षर
|
||||
Protect & Standardize सुरक्षित और मानकीकृत करें
|
||||
Customize & Automate अनुकूलित और स्वचालित करें
|
||||
No tools match your search. आपकी खोज से कोई टूल मेल नहीं खाता।
|
||||
Expand all tools सभी टूल विस्तृत करें
|
||||
Collapse all tools सभी टूल समेटें
|
||||
The password must have at least 8 characters. पासवर्ड में कम से कम 8 वर्ण होने चाहिए।
|
||||
theme Appearance स्वरूप
|
||||
Zoom — type a percentage and press Enter ज़ूम — प्रतिशत टाइप करें और Enter दबाएँ
|
||||
@@ -2768,3 +2776,4 @@ theme Appearance स्वरूप
|
||||
Attaching files is not available on the web yet वेब पर अभी फ़ाइलें संलग्न करना उपलब्ध नहीं है
|
||||
Delete attachment अनुलग्नक हटाएँ
|
||||
Delete attachment {key} अनुलग्नक {key} हटाएँ
|
||||
@plural {n} reply|{n} replies {n} जवाब
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 855 and column 12.
|
@@ -2488,6 +2488,14 @@ animation effects Off Ki
|
||||
Make properties default Tulajdonságok beállítása alapértelmezettként
|
||||
Pages selected Kijelölt oldalak
|
||||
Page properties Oldaltulajdonságok
|
||||
Create & Edit Létrehozás és szerkesztés
|
||||
Review & Compare Áttekintés és összehasonlítás
|
||||
Forms & Signatures Űrlapok és aláírások
|
||||
Protect & Standardize Védelem és szabványosítás
|
||||
Customize & Automate Testreszabás és automatizálás
|
||||
No tools match your search. Egy eszköz sem felel meg a keresésnek.
|
||||
Expand all tools Az összes eszköz kibontása
|
||||
Collapse all tools Az összes eszköz összecsukása
|
||||
Attach a file… Fájl csatolása…
|
||||
Delete attachment Melléklet törlése
|
||||
Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el
|
||||
@@ -2530,3 +2538,4 @@ animation effects Off Ki
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Ma
|
||||
@plural {n} reply|{n} replies {n} válasz|{n} válasz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2683,6 +2683,14 @@ key Space Space
|
||||
Make properties default Jadikan properti default
|
||||
Pages selected Halaman yang dipilih
|
||||
Page properties Properti halaman
|
||||
Create & Edit Buat & Edit
|
||||
Review & Compare Tinjau & Bandingkan
|
||||
Forms & Signatures Formulir & Tanda Tangan
|
||||
Protect & Standardize Lindungi & Standarkan
|
||||
Customize & Automate Sesuaikan & Otomatiskan
|
||||
No tools match your search. Tidak ada alat yang cocok dengan pencarian Anda.
|
||||
Expand all tools Perluas semua alat
|
||||
Collapse all tools Ciutkan semua alat
|
||||
The password must have at least 8 characters. Kata sandi harus memiliki minimal 8 karakter.
|
||||
theme Appearance Tampilan
|
||||
Zoom — type a percentage and press Enter Zoom — ketik persentase dan tekan Enter
|
||||
@@ -2769,3 +2777,4 @@ theme Appearance Tampilan
|
||||
Attaching files is not available on the web yet Melampirkan file belum tersedia di web
|
||||
Delete attachment Hapus lampiran
|
||||
Delete attachment {key} Hapus lampiran {key}
|
||||
@plural {n} reply|{n} replies {n} balasan
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 483 and column 12.
|
@@ -2701,6 +2701,14 @@ certificate Key usage Utilizzo chiave
|
||||
certificate Self-signed Autofirmato
|
||||
certificate SHA-1 digest Impronta SHA-1
|
||||
certificate SHA-256 digest Impronta SHA-256
|
||||
Create & Edit Crea e modifica
|
||||
Review & Compare Revisione e confronto
|
||||
Forms & Signatures Moduli e firme
|
||||
Protect & Standardize Proteggi e standardizza
|
||||
Customize & Automate Personalizza e automatizza
|
||||
No tools match your search. Nessuno strumento corrisponde alla ricerca.
|
||||
Expand all tools Espandi tutti gli strumenti
|
||||
Collapse all tools Comprimi tutti gli strumenti
|
||||
Attach a file… Allega un file…
|
||||
Delete attachment Elimina allegato
|
||||
Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web
|
||||
@@ -2763,3 +2771,4 @@ certificate SHA-256 digest Impronta SHA-256
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Oggi
|
||||
@plural {n} reply|{n} replies {n} risposta|{n} risposte
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2489,6 +2489,14 @@ animation effects Off オフ
|
||||
Make properties default プロパティをデフォルトに設定
|
||||
Pages selected 選択したページ
|
||||
Page properties ページのプロパティ
|
||||
Create & Edit 作成と編集
|
||||
Review & Compare レビューと比較
|
||||
Forms & Signatures フォームと署名
|
||||
Protect & Standardize 保護と標準化
|
||||
Customize & Automate カスタマイズと自動化
|
||||
No tools match your search. 検索に一致するツールはありません。
|
||||
Expand all tools すべてのツールを展開
|
||||
Collapse all tools すべてのツールを折りたたむ
|
||||
Attach a file… ファイルを添付…
|
||||
Delete attachment 添付ファイルを削除
|
||||
Attaching files is not available on the web yet Web 版ではまだファイルを添付できません
|
||||
@@ -2531,3 +2539,4 @@ animation effects Off オフ
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今日
|
||||
@plural {n} reply|{n} replies {n} 件の返信
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2487,6 +2487,14 @@ animation effects Off Өшірулі
|
||||
Make properties default Қасиеттерді әдепкі ету
|
||||
Pages selected Таңдалған беттер
|
||||
Page properties Бет қасиеттері
|
||||
Create & Edit Жасау және өңдеу
|
||||
Review & Compare Шолу және салыстыру
|
||||
Forms & Signatures Пішіндер және қолтаңбалар
|
||||
Protect & Standardize Қорғау және стандарттау
|
||||
Customize & Automate Баптау және автоматтандыру
|
||||
No tools match your search. Іздеуге сәйкес құралдар жоқ.
|
||||
Expand all tools Барлық құралдарды жаю
|
||||
Collapse all tools Барлық құралдарды жию
|
||||
Attach a file… Файл тіркеу…
|
||||
Delete attachment Тіркемені жою
|
||||
Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз
|
||||
@@ -2529,3 +2537,4 @@ animation effects Off Өшірулі
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Бүгін
|
||||
@plural {n} reply|{n} replies {n} жауап|{n} жауап
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 860 and column 9.
|
@@ -2682,6 +2682,14 @@ certificate SHA-256 digest SHA-256 다이제스트
|
||||
Make properties default 속성을 기본값으로 설정
|
||||
Pages selected 선택한 페이지
|
||||
Page properties 페이지 속성
|
||||
Create & Edit 만들기 및 편집
|
||||
Review & Compare 검토 및 비교
|
||||
Forms & Signatures 양식 및 서명
|
||||
Protect & Standardize 보호 및 표준화
|
||||
Customize & Automate 사용자 지정 및 자동화
|
||||
No tools match your search. 검색과 일치하는 도구가 없습니다.
|
||||
Expand all tools 모든 도구 펼치기
|
||||
Collapse all tools 모든 도구 접기
|
||||
The password must have at least 8 characters. 암호는 8자 이상이어야 합니다.
|
||||
theme Appearance 모양
|
||||
Zoom — type a percentage and press Enter 줌 — 백분율을 입력하고 Enter를 누르세요
|
||||
@@ -2768,3 +2776,4 @@ theme Appearance 모양
|
||||
Attaching files is not available on the web yet 웹에서는 아직 파일을 첨부할 수 없습니다
|
||||
Delete attachment 첨부 파일 삭제
|
||||
Delete attachment {key} 첨부 파일 {key} 삭제
|
||||
@plural {n} reply|{n} replies 답글 {n}개
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 855 and column 12.
|
@@ -2436,6 +2436,15 @@ animation effects Off ປິດ
|
||||
Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ
|
||||
Pages selected ໜ້າທີ່ເລືອກ
|
||||
Page properties ຄຸນສົມບັດໜ້າ
|
||||
Create & Edit ສ້າງ & ແກ້ໄຂ
|
||||
Review & Compare ກວດກາຄືນ & ປຽບທຽບ
|
||||
Forms & Signatures ຟອມ & ລາຍເຊັນ
|
||||
Protect & Standardize ປ້ອງກັນ & ມາດຕະຖານ
|
||||
Customize & Automate ປັບແຕ່ງ & ອັດຕະໂນມັດ
|
||||
No tools match your search. ບໍ່ມີເຄື່ອງມືທີ່ກົງກັບການຄົ້ນຫາຂອງທ່ານ.
|
||||
Expand all tools ຂະຫຍາຍເຄື່ອງມືທັງໝົດ
|
||||
Collapse all tools ຫຍໍ້ເຄື່ອງມືທັງໝົດເຂົ້າ
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ມື້ນີ້
|
||||
@plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 32.
|
@@ -2739,6 +2739,14 @@ key Space Tarpas
|
||||
Make properties default Nustatyti ypatybes kaip numatytąsias
|
||||
Pages selected Pasirinkti puslapiai
|
||||
Page properties Puslapio ypatybės
|
||||
Create & Edit Kūrimas ir redagavimas
|
||||
Review & Compare Peržiūra ir palyginimas
|
||||
Forms & Signatures Formos ir parašai
|
||||
Protect & Standardize Apsauga ir standartizavimas
|
||||
Customize & Automate Pritaikymas ir automatizavimas
|
||||
No tools match your search. Paieškos neatitinka joks įrankis.
|
||||
Expand all tools Išskleisti visus įrankius
|
||||
Collapse all tools Suskleisti visus įrankius
|
||||
|
||||
# AI-assisted additions for concurrently merged UI features (2026-10-15).
|
||||
# These additions have not had separate native-speaker review.
|
||||
@@ -2792,3 +2800,4 @@ theme Appearance Išvaizda
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Šiandien
|
||||
@plural {n} reply|{n} replies {n} atsakymas|{n} atsakymai|{n} atsakymų
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 406 and column 9.
|
@@ -1794,6 +1794,7 @@ mod tests {
|
||||
section.items.iter().for_each(|item| add(item.label));
|
||||
}
|
||||
}
|
||||
pdfcraft_engine::catalog::TOOL_CATEGORIES.iter().for_each(|category| add(category.label));
|
||||
let engine: [&[&str]; 9] = [
|
||||
pdfcraft_engine::a11y::WORDINGS,
|
||||
pdfcraft_engine::a11y::REPORT_WORDS,
|
||||
|
||||
@@ -2614,6 +2614,14 @@
|
||||
Make properties default Bruk egenskapene som standard
|
||||
Pages selected Valgte sider
|
||||
Page properties Sideegenskaper
|
||||
Create & Edit Opprett og rediger
|
||||
Review & Compare Gjennomgå og sammenlign
|
||||
Forms & Signatures Skjemaer og signaturer
|
||||
Protect & Standardize Beskytt og standardiser
|
||||
Customize & Automate Tilpass og automatiser
|
||||
No tools match your search. Ingen verktøy samsvarer med søket.
|
||||
Expand all tools Utvid alle verktøy
|
||||
Collapse all tools Fold sammen alle verktøy
|
||||
Cut Image Klipp ut bilde
|
||||
Redact Image Sladd bilde
|
||||
Create Link… Opprett lenke…
|
||||
@@ -2707,3 +2715,4 @@ key Space Mellomrom
|
||||
|
||||
# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker.
|
||||
Restore the last view (page, zoom and page display) when a document is opened again Gjenopprett den siste visningen (side, zoom og sidevisning) når et dokument åpnes igjen
|
||||
@plural {n} reply|{n} replies {n} svar|{n} svar
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2465,6 +2465,14 @@ animation effects Off Wyłączone
|
||||
Make properties default Ustaw właściwości jako domyślne
|
||||
Pages selected Zaznaczone strony
|
||||
Page properties Właściwości strony
|
||||
Create & Edit Tworzenie i edycja
|
||||
Review & Compare Recenzja i porównanie
|
||||
Forms & Signatures Formularze i podpisy
|
||||
Protect & Standardize Ochrona i standaryzacja
|
||||
Customize & Automate Dostosowanie i automatyzacja
|
||||
No tools match your search. Żadne narzędzie nie pasuje do wyszukiwania.
|
||||
Expand all tools Rozwiń wszystkie narzędzia
|
||||
Collapse all tools Zwiń wszystkie narzędzia
|
||||
Attach a file… Dołącz plik…
|
||||
Delete attachment Usuń załącznik
|
||||
Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej
|
||||
@@ -2627,3 +2635,4 @@ animation effects Off Wyłączone
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Dzisiaj
|
||||
@plural {n} reply|{n} replies {n} odpowiedź|{n} odpowiedzi|{n} odpowiedzi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2503,6 +2503,14 @@ animation effects Off Desativados
|
||||
Make properties default Tornar propriedades padrão
|
||||
Pages selected Páginas selecionadas
|
||||
Page properties Propriedades da página
|
||||
Create & Edit Criar e editar
|
||||
Review & Compare Revisar e comparar
|
||||
Forms & Signatures Formulários e assinaturas
|
||||
Protect & Standardize Proteger e padronizar
|
||||
Customize & Automate Personalizar e automatizar
|
||||
No tools match your search. Nenhuma ferramenta corresponde à pesquisa.
|
||||
Expand all tools Expandir todas as ferramentas
|
||||
Collapse all tools Recolher todas as ferramentas
|
||||
Attach a file… Anexar um arquivo…
|
||||
Delete attachment Excluir anexo
|
||||
Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web
|
||||
@@ -2816,3 +2824,4 @@ theme Appearance Aparência
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hoje
|
||||
@plural {n} reply|{n} replies {n} resposta|{n} respostas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2654,6 +2654,14 @@ key Space Spațiu
|
||||
Optimizing… merging image fragments Se optimizează… unificare fragmente de imagine
|
||||
Outline colour: Culoare contur:
|
||||
Page properties Proprietăți pagină
|
||||
Create & Edit Creare și editare
|
||||
Review & Compare Revizuire și comparare
|
||||
Forms & Signatures Formulare și semnături
|
||||
Protect & Standardize Protejare și standardizare
|
||||
Customize & Automate Personalizare și automatizare
|
||||
No tools match your search. Niciun instrument nu corespunde căutării.
|
||||
Expand all tools Extinderea tuturor instrumentelor
|
||||
Collapse all tools Restrângerea tuturor instrumentelor
|
||||
Pages counted Pagini numărate
|
||||
Pages selected Pagini selectate
|
||||
Print as image Imprimare ca imagine
|
||||
@@ -2792,3 +2800,4 @@ theme Appearance Aspect
|
||||
Attaching files is not available on the web yet Atașarea fișierelor nu este încă disponibilă pe web
|
||||
Delete attachment Ștergere atașament
|
||||
Delete attachment {key} Ștergere atașament {key}
|
||||
@plural {n} reply|{n} replies {n} răspuns|{n} răspunsuri|{n} de răspunsuri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 110.
|
@@ -2402,6 +2402,14 @@ animation effects Off Выкл
|
||||
Make properties default Сделать свойства стандартными
|
||||
Pages selected Выделенные страницы
|
||||
Page properties Свойства страницы
|
||||
Create & Edit Создание и редактирование
|
||||
Review & Compare Рецензирование и сравнение
|
||||
Forms & Signatures Формы и подписи
|
||||
Protect & Standardize Защита и стандартизация
|
||||
Customize & Automate Настройка и автоматизация
|
||||
No tools match your search. Нет инструментов, соответствующих запросу.
|
||||
Expand all tools Развернуть все инструменты
|
||||
Collapse all tools Свернуть все инструменты
|
||||
Attach a file… Прикрепить файл…
|
||||
Delete attachment Удалить вложение
|
||||
Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно
|
||||
@@ -2444,3 +2452,4 @@ animation effects Off Выкл
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сегодня
|
||||
@plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2494,6 +2494,14 @@ animation effects Off Izklopljeni
|
||||
Make properties default Nastavi lastnosti kot privzete
|
||||
Pages selected Izbrane strani
|
||||
Page properties Lastnosti strani
|
||||
Create & Edit Ustvarjanje in urejanje
|
||||
Review & Compare Pregled in primerjava
|
||||
Forms & Signatures Obrazci in podpisi
|
||||
Protect & Standardize Zaščita in standardizacija
|
||||
Customize & Automate Prilagajanje in avtomatizacija
|
||||
No tools match your search. Iskanju ne ustreza nobeno orodje.
|
||||
Expand all tools Razširi vsa orodja
|
||||
Collapse all tools Strni vsa orodja
|
||||
Attach a file… Priloži datoteko…
|
||||
Delete attachment Izbriši prilogo
|
||||
Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo
|
||||
@@ -2536,3 +2544,4 @@ animation effects Off Izklopljeni
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Danes
|
||||
@plural {n} reply|{n} replies {n} odgovor|{n} odgovora|{n} odgovori|{n} odgovorov
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2420,6 +2420,14 @@ animation effects Off Joaktiv
|
||||
Other options Other options
|
||||
Outline colour: Outline colour:
|
||||
Page properties Page properties
|
||||
Create & Edit Krijo dhe redakto
|
||||
Review & Compare Rishiko dhe krahaso
|
||||
Forms & Signatures Formularë dhe nënshkrime
|
||||
Protect & Standardize Mbro dhe standardizo
|
||||
Customize & Automate Personalizo dhe automatizo
|
||||
No tools match your search. Asnjë mjet nuk përputhet me kërkimin tuaj.
|
||||
Expand all tools Zgjero të gjitha mjetet
|
||||
Collapse all tools Palos të gjitha mjetet
|
||||
Pages counted Pages counted
|
||||
Pages selected Pages selected
|
||||
Paragraph spacing Paragraph spacing
|
||||
@@ -2518,3 +2526,4 @@ animation effects Off Joaktiv
|
||||
Attaching files is not available on the web yet Bashkëngjitja e skedarëve nuk është ende e disponueshme në ueb
|
||||
Delete attachment Fshi bashkëngjitjen
|
||||
Delete attachment {key} Fshi bashkëngjitjen {key}
|
||||
@plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2398,6 +2398,14 @@ animation effects Off ఆఫ్
|
||||
Make properties default లక్షణాలను డిఫాల్ట్గా చేయి
|
||||
Pages selected ఎంచుకున్న పేజీలు
|
||||
Page properties పేజీ లక్షణాలు
|
||||
Create & Edit సృష్టించు & సవరించు
|
||||
Review & Compare సమీక్షించు & పోల్చు
|
||||
Forms & Signatures ఫారమ్లు & సంతకాలు
|
||||
Protect & Standardize రక్షించు & ప్రామాణీకరించు
|
||||
Customize & Automate అనుకూలీకరించు & ఆటోమేట్ చేయి
|
||||
No tools match your search. మీ శోధనకు సరిపోలే సాధనాలు లేవు.
|
||||
Expand all tools అన్ని సాధనాలను విస్తరించు
|
||||
Collapse all tools అన్ని సాధనాలను కుదించు
|
||||
Attach a file… ఫైల్ను జోడించు…
|
||||
Delete attachment జోడింపును తొలగించు
|
||||
Attaching files is not available on the web yet వెబ్లో ఫైల్లను జోడించడం ఇంకా అందుబాటులో లేదు
|
||||
@@ -2440,3 +2448,4 @@ animation effects Off ఆఫ్
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ఈరోజు
|
||||
@plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2686,6 +2686,14 @@ key Space Space
|
||||
Make properties default ตั้งคุณสมบัติเป็นค่าเริ่มต้น
|
||||
Pages selected หน้าที่เลือก
|
||||
Page properties คุณสมบัติหน้า
|
||||
Create & Edit สร้างและแก้ไข
|
||||
Review & Compare ตรวจทานและเปรียบเทียบ
|
||||
Forms & Signatures แบบฟอร์มและลายเซ็น
|
||||
Protect & Standardize ป้องกันและกำหนดมาตรฐาน
|
||||
Customize & Automate ปรับแต่งและทำงานอัตโนมัติ
|
||||
No tools match your search. ไม่มีเครื่องมือที่ตรงกับการค้นหาของคุณ
|
||||
Expand all tools ขยายเครื่องมือทั้งหมด
|
||||
Collapse all tools ยุบเครื่องมือทั้งหมด
|
||||
The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร
|
||||
theme Appearance ลักษณะที่ปรากฏ
|
||||
|
||||
@@ -2694,3 +2702,4 @@ theme Appearance ลักษณะที่ปรากฏ
|
||||
|
||||
# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker.
|
||||
Restore the last view (page, zoom and page display) when a document is opened again คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง
|
||||
@plural {n} reply|{n} replies การตอบกลับ {n} รายการ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 866 and column 12.
|
@@ -2485,6 +2485,14 @@ animation effects Off Kapalı
|
||||
Make properties default Özellikleri varsayılan yap
|
||||
Pages selected Seçilen sayfalar
|
||||
Page properties Sayfa özellikleri
|
||||
Create & Edit Oluştur ve Düzenle
|
||||
Review & Compare İncele ve Karşılaştır
|
||||
Forms & Signatures Formlar ve İmzalar
|
||||
Protect & Standardize Koru ve Standartlaştır
|
||||
Customize & Automate Özelleştir ve Otomatikleştir
|
||||
No tools match your search. Aramanızla eşleşen araç yok.
|
||||
Expand all tools Tüm araçları genişlet
|
||||
Collapse all tools Tüm araçları daralt
|
||||
Attach a file… Dosya ekle…
|
||||
Delete attachment Eki sil
|
||||
Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor
|
||||
@@ -2527,3 +2535,4 @@ animation effects Off Kapalı
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Bugün
|
||||
@plural {n} reply|{n} replies {n} yanıt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 859 and column 9.
|
@@ -2511,6 +2511,14 @@ animation effects Off Вимкнено
|
||||
Make properties default Зробити властивості типовими
|
||||
Pages selected Вибрані сторінки
|
||||
Page properties Властивості сторінки
|
||||
Create & Edit Створення й редагування
|
||||
Review & Compare Рецензування й порівняння
|
||||
Forms & Signatures Форми й підписи
|
||||
Protect & Standardize Захист і стандартизація
|
||||
Customize & Automate Налаштування й автоматизація
|
||||
No tools match your search. Немає інструментів, що відповідають пошуку.
|
||||
Expand all tools Розгорнути всі інструменти
|
||||
Collapse all tools Згорнути всі інструменти
|
||||
Attach a file… Прикріпити файл…
|
||||
Delete attachment Видалити вкладення
|
||||
Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне
|
||||
@@ -2553,3 +2561,4 @@ animation effects Off Вимкнено
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сьогодні
|
||||
@plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2676,6 +2676,14 @@ key Space Space
|
||||
Redact Image Xóa nội dung nhạy cảm của hình ảnh
|
||||
Create Link… Tạo liên kết…
|
||||
Recognize Text… Nhận dạng văn bản…
|
||||
Create & Edit Tạo và chỉnh sửa
|
||||
Review & Compare Xem xét và so sánh
|
||||
Forms & Signatures Biểu mẫu và chữ ký
|
||||
Protect & Standardize Bảo vệ và chuẩn hóa
|
||||
Customize & Automate Tùy chỉnh và tự động hóa
|
||||
No tools match your search. Không có công cụ nào khớp với tìm kiếm.
|
||||
Expand all tools Mở rộng tất cả công cụ
|
||||
Collapse all tools Thu gọn tất cả công cụ
|
||||
|
||||
# AI-assisted additions for concurrently merged UI features (2026-10-15).
|
||||
# These additions have not had separate native-speaker review.
|
||||
@@ -2798,3 +2806,4 @@ key Space Space
|
||||
|
||||
# AI-assisted additions for restoring the last view (#1767) (2026-10-16); not reviewed by a native speaker.
|
||||
Restore the last view (page, zoom and page display) when a document is opened again Khôi phục chế độ xem gần nhất (trang, thu phóng và hiển thị trang) khi mở lại tài liệu
|
||||
@plural {n} reply|{n} replies {n} câu trả lời
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 493 and column 12.
|
@@ -2453,6 +2453,14 @@ animation effects Off 关
|
||||
Make properties default 将属性设为默认
|
||||
Pages selected 选定的页面
|
||||
Page properties 页面属性
|
||||
Create & Edit 创建和编辑
|
||||
Review & Compare 审阅和比较
|
||||
Forms & Signatures 表单和签名
|
||||
Protect & Standardize 保护和标准化
|
||||
Customize & Automate 自定义和自动化
|
||||
No tools match your search. 没有与搜索匹配的工具。
|
||||
Expand all tools 展开所有工具
|
||||
Collapse all tools 折叠所有工具
|
||||
Attach a file… 附加文件…
|
||||
Delete attachment 删除附件
|
||||
Attaching files is not available on the web yet 网页版尚不支持附加文件
|
||||
@@ -2495,3 +2503,4 @@ animation effects Off 关
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@plural {n} reply|{n} replies {n} 条回复
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2439,6 +2439,14 @@ animation effects Off 關
|
||||
Make properties default 將內容設為預設
|
||||
Pages selected 選取的頁面
|
||||
Page properties 頁面屬性
|
||||
Create & Edit 建立與編輯
|
||||
Review & Compare 審閱與比較
|
||||
Forms & Signatures 表單與簽名
|
||||
Protect & Standardize 保護與標準化
|
||||
Customize & Automate 自訂與自動化
|
||||
No tools match your search. 沒有符合搜尋的工具。
|
||||
Expand all tools 展開所有工具
|
||||
Collapse all tools 摺疊所有工具
|
||||
Attach a file… 附加檔案…
|
||||
Delete attachment 刪除附件
|
||||
Attaching files is not available on the web yet 網頁版尚不支援附加檔案
|
||||
@@ -2481,3 +2489,4 @@ animation effects Off 關
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@plural {n} reply|{n} replies {n} 則回覆
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -32,6 +32,7 @@ macro_rules! tl_ctx {
|
||||
|
||||
mod a11y_ui;
|
||||
mod actions_ui;
|
||||
mod all_tools_ui;
|
||||
mod appearance;
|
||||
mod calc_order_ui;
|
||||
pub mod canvas;
|
||||
@@ -101,6 +102,7 @@ pub mod icons;
|
||||
pub mod last_session;
|
||||
pub mod last_view;
|
||||
pub mod motion;
|
||||
mod page_pointer;
|
||||
mod page_selection_ui;
|
||||
mod pageboxes;
|
||||
mod palette;
|
||||
@@ -376,6 +378,7 @@ pub enum PropsTab {
|
||||
Fonts,
|
||||
Custom,
|
||||
Advanced,
|
||||
SpaceAudit,
|
||||
}
|
||||
|
||||
/// Document Properties ▸ Custom being edited: the properties as they will be after OK, in order,
|
||||
@@ -706,6 +709,12 @@ pub struct PdfCraftApp {
|
||||
pub palette_open: bool,
|
||||
pub palette_query: String,
|
||||
pub all_tools_expanded: bool,
|
||||
/// The All tools view fills the workspace (its sidebar's expand button).
|
||||
pub all_tools_full: bool,
|
||||
pub all_tools_query: String,
|
||||
/// The tab that showed when the expanded All tools view opened ([`Self::shown_tab`]). Another
|
||||
/// tab coming to the front (Home, a document, Combine files) puts the view away.
|
||||
all_tools_tab: (Option<DocId>, bool),
|
||||
pub recent: Vec<RecentFile>,
|
||||
/// Preferences: reopen the files that were open when PdfCraft last closed (#442).
|
||||
pub reopen_last_session: bool,
|
||||
@@ -848,6 +857,8 @@ pub struct PdfCraftApp {
|
||||
pub cert_viewer: Option<sign_ui::CertViewer>,
|
||||
/// The last space audit.
|
||||
pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>,
|
||||
/// The document and edit generation `space_audit` was measured from.
|
||||
pub(crate) space_audit_of: Option<(DocId, u64)>,
|
||||
/// Combine files: the files staged so far.
|
||||
pub combine_draft: Vec<combine_ui::CombineFile>,
|
||||
/// The combine list's field-name conflict preview (computed once per list, never saved).
|
||||
@@ -979,6 +990,8 @@ pub struct PdfCraftApp {
|
||||
pub replace_draft: Option<files::ReplaceDraft>,
|
||||
/// The last web link the app asked the system to open (tests and automation).
|
||||
pub last_opened_url: Option<String>,
|
||||
/// The home screen's community card was closed; it comes back next launch (not remembered).
|
||||
pub community_card_closed: bool,
|
||||
/// A document asked to open this address; the user hasn't answered yet (#90, #91).
|
||||
pub pending_link: Option<PendingLink>,
|
||||
/// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`):
|
||||
@@ -1127,6 +1140,9 @@ impl PdfCraftApp {
|
||||
palette_open: false,
|
||||
palette_query: String::new(),
|
||||
all_tools_expanded: false,
|
||||
all_tools_full: false,
|
||||
all_tools_query: String::new(),
|
||||
all_tools_tab: (None, false),
|
||||
recent: Vec::new(),
|
||||
reopen_last_session: false,
|
||||
restore_last_view: true,
|
||||
@@ -1208,6 +1224,7 @@ impl PdfCraftApp {
|
||||
os_key_store_ids: false,
|
||||
cert_viewer: None,
|
||||
space_audit: Vec::new(),
|
||||
space_audit_of: None,
|
||||
combine_draft: Vec::new(),
|
||||
combine_conflicts: Default::default(),
|
||||
combine_tab: Default::default(),
|
||||
@@ -1252,6 +1269,7 @@ impl PdfCraftApp {
|
||||
control: None,
|
||||
bookmark_rename: None,
|
||||
last_opened_url: None,
|
||||
community_card_closed: false,
|
||||
pending_link: None,
|
||||
pending_full_screen: None,
|
||||
pending_remote_link: None,
|
||||
@@ -1593,6 +1611,10 @@ impl PdfCraftApp {
|
||||
{
|
||||
view.open_organize();
|
||||
}
|
||||
// The document shows, not the expanded All tools view over it (whichever workspace opens).
|
||||
if activate {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
if activate && let Some(mode) = self.mode_override {
|
||||
// Explicit mode options do not reset independent --tool / --left choices.
|
||||
self.mode = mode;
|
||||
@@ -2455,6 +2477,8 @@ impl PdfCraftApp {
|
||||
/// Enter a tool from the catalogue, Home, palette or control channel. Organize is a
|
||||
/// workspace, so opening its panel also opens the page grid (without toggling it off).
|
||||
pub(crate) fn select_tool(&mut self, id: &'static str) {
|
||||
// The tool's panel shows, not the expanded All tools view over it.
|
||||
self.all_tools_full = false;
|
||||
self.left = LeftPanel::Tool(id);
|
||||
self.reveal_tools();
|
||||
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) {
|
||||
@@ -2489,6 +2513,10 @@ impl PdfCraftApp {
|
||||
/// Edit puts the editing tools down again and applies a paragraph still open in the editor.
|
||||
pub(crate) fn enter_mode(&mut self, mode: Mode) {
|
||||
self.mode = mode;
|
||||
// Another workspace puts the expanded All tools view away.
|
||||
if mode != Mode::AllTools {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
if mode == Mode::Edit {
|
||||
self.quick_tool = QuickTool::EditText;
|
||||
} else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
|
||||
@@ -2501,6 +2529,21 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the workspace with the expanded All tools view, entering All tools the way the mode
|
||||
/// bar does: leaving Read restores its right panel and leaving Edit puts the editing tool down.
|
||||
pub(crate) fn expand_all_tools(&mut self) {
|
||||
if self.mode != Mode::AllTools {
|
||||
self.switch_mode(Mode::AllTools, true);
|
||||
}
|
||||
self.all_tools_full = true;
|
||||
self.all_tools_tab = self.shown_tab();
|
||||
}
|
||||
|
||||
/// The tab at the front: the active document, or else whether Combine files shows over Home.
|
||||
fn shown_tab(&self) -> (Option<DocId>, bool) {
|
||||
(self.active.and_then(|i| self.views.get(i)).map(|v| v.id), self.combine_showing())
|
||||
}
|
||||
|
||||
/// Back to the Select tool from an editing tool. A paragraph still open in the editor is
|
||||
/// applied, as clicking away from it does; left open, its page would keep showing the
|
||||
/// paragraph's text removed with no editor over it.
|
||||
@@ -2885,6 +2928,10 @@ impl PdfCraftApp {
|
||||
let mode = Mode::parse(value).unwrap_or(Mode::AllTools);
|
||||
self.mode_override = Some(mode);
|
||||
self.mode = mode;
|
||||
// As switch_mode: another workspace puts the expanded All tools view away.
|
||||
if mode != Mode::AllTools {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
}
|
||||
("default-mode", _) => {
|
||||
self.default_mode = Mode::parse(value).ok_or("default-mode must be all, read, edit, convert or sign")?;
|
||||
@@ -2896,7 +2943,12 @@ impl PdfCraftApp {
|
||||
self.select_tool(g.id);
|
||||
}
|
||||
("left", _) => self.left_open = value != "closed",
|
||||
("home", _) => self.active = None,
|
||||
("home", _) => {
|
||||
self.active = None;
|
||||
// With no document open the expanded All tools view covers Home itself, so no
|
||||
// other tab comes forward to put it away.
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
("dialog", _) => {
|
||||
self.dialog = match value {
|
||||
"properties" => Some(Dialog::Properties(PropsTab::Description)),
|
||||
@@ -2904,6 +2956,7 @@ impl PdfCraftApp {
|
||||
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
|
||||
"custom" => Some(Dialog::Properties(PropsTab::Custom)),
|
||||
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
|
||||
"space-audit" => Some(Dialog::Properties(PropsTab::SpaceAudit)),
|
||||
"shortcuts" => Some(Dialog::Shortcuts),
|
||||
"split" => Some(Dialog::Split),
|
||||
"protect" => Some(Dialog::Protect),
|
||||
@@ -2940,7 +2993,11 @@ impl PdfCraftApp {
|
||||
_ => Some(Dialog::About),
|
||||
}
|
||||
}
|
||||
("tools", _) => self.all_tools_expanded = value != "collapsed",
|
||||
("tools", _) if value == "full" => self.expand_all_tools(),
|
||||
("tools", _) => {
|
||||
self.all_tools_full = false;
|
||||
self.all_tools_expanded = value != "collapsed";
|
||||
}
|
||||
("palette", _) => {
|
||||
self.palette_open = true;
|
||||
self.palette_query = value.to_string();
|
||||
@@ -3653,6 +3710,10 @@ impl eframe::App for PdfCraftApp {
|
||||
.show(ui, |ui| panels::right_panel(self, ui));
|
||||
}
|
||||
}
|
||||
// Whichever way another tab came to the front, it shows instead of the expanded view.
|
||||
if self.all_tools_full && self.all_tools_tab != self.shown_tab() {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
|
||||
if self.mode == Mode::Read {
|
||||
@@ -3664,6 +3725,8 @@ impl eframe::App for PdfCraftApp {
|
||||
None => canvas::document_area(self, index, ui),
|
||||
},
|
||||
}
|
||||
} else if self.mode == Mode::AllTools && self.all_tools_full {
|
||||
all_tools_ui::show(self, ui);
|
||||
} else {
|
||||
docking::show(self, ui);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
@@ -56,21 +57,23 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
|
||||
}
|
||||
|
||||
/// Link tool input on one page. Returns `true` when the gesture belongs to the tool.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
links: &[LinkItem],
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
|
||||
let pointer = ptr.pos;
|
||||
let lv = &mut view.links;
|
||||
if let Some((dp, start)) = lv.drag
|
||||
&& dp == page
|
||||
{
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
lv.drag = None;
|
||||
let end = pointer.unwrap_or(start);
|
||||
let r = Rect::from_two_pos(start, end).intersect(xf.rect);
|
||||
@@ -104,7 +107,7 @@ pub(crate) fn page_input(
|
||||
return true;
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
lv.drag = Some((page, origin));
|
||||
return true;
|
||||
}
|
||||
@@ -112,7 +115,7 @@ pub(crate) fn page_input(
|
||||
}
|
||||
|
||||
/// Outlines of the page's links (selected one bold) and the rectangle being drawn.
|
||||
pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) {
|
||||
pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView, pointer: Option<Pos2>) {
|
||||
for l in links.iter().filter(|l| l.page == page) {
|
||||
let r = screen_rect(xf, info, page, l.rect);
|
||||
let selected = view.links.selected == Some((page, l.index));
|
||||
@@ -123,7 +126,7 @@ pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos()))
|
||||
if let (Some((dp, start)), Some(p)) = (view.links.drag, pointer)
|
||||
&& dp == page
|
||||
{
|
||||
let r = Rect::from_two_pos(start, p);
|
||||
|
||||
@@ -196,7 +196,7 @@ pub(crate) fn page_input(
|
||||
let v = info.user_to_view(p[0] as f32, p[1] as f32);
|
||||
xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph)
|
||||
};
|
||||
let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p));
|
||||
let pos = cx.ptr.within(xf.rect);
|
||||
let mut hover = None;
|
||||
let mut snap = None;
|
||||
if let Some(pos) = pos {
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
|
||||
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
|
||||
@@ -156,18 +157,24 @@ fn leave_single(view: &mut DocView) {
|
||||
|
||||
/// Returns true while group selection owns the interaction. Ordinary clicks still reach the
|
||||
/// single-object text/resize tools. A text draft is finished or discarded through its editor.
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool {
|
||||
pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
doc: &Document,
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
if view.objects.input_blocked {
|
||||
return true;
|
||||
}
|
||||
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
|
||||
let blocked = view.objects.blocked_page == Some(page);
|
||||
let modifier_press = ui.input(|i| i.pointer.primary_pressed())
|
||||
&& modified(ui)
|
||||
&& ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p));
|
||||
let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p));
|
||||
if blocked || modifier_press {
|
||||
view.objects.hold_editor = true;
|
||||
view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page);
|
||||
view.objects.blocked_page = ptr.primary_down.then_some(page);
|
||||
if modifier_press {
|
||||
view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into());
|
||||
}
|
||||
@@ -176,14 +183,24 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
|
||||
return false;
|
||||
}
|
||||
let mut selection = std::mem::take(&mut view.objects);
|
||||
let result = input(&mut selection, ui, resp, xf, page, doc, view);
|
||||
let result = input(&mut selection, ui, resp, xf, ptr, page, doc, view);
|
||||
view.objects = selection;
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool {
|
||||
let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down()));
|
||||
fn input(
|
||||
s: &mut ObjectSelection,
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
doc: &Document,
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let pointer = ptr.pos;
|
||||
let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down);
|
||||
let info = &doc.info;
|
||||
if s.page == Some(page)
|
||||
&& let Some(mut gesture) = s.gesture
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use egui::{Align, Layout, RichText};
|
||||
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings};
|
||||
use pdfcraft_engine::optimizer::{OptimizeStage, Optimized};
|
||||
use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport};
|
||||
@@ -267,9 +267,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
|
||||
/// Audit Space Usage: how the file's bytes are spent now, by kind of content (not what
|
||||
/// optimizing would save).
|
||||
pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool {
|
||||
use egui::{RichText, TextWrapMode, WidgetText};
|
||||
ui.label(RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0)));
|
||||
ui.add_space(4.0);
|
||||
audit_table(ui, rows, t);
|
||||
ui.add_space(12.0);
|
||||
let mut ok = false;
|
||||
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked()));
|
||||
ok
|
||||
}
|
||||
|
||||
/// The audit's explanation and its table: bytes and share of the file per kind of content.
|
||||
/// Shared by the Optimizer's Audit Space Usage and Properties ▸ Space Audit.
|
||||
pub(crate) fn audit_table(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) {
|
||||
use egui::{TextWrapMode, WidgetText};
|
||||
ui.label(RichText::new(tl!("What takes up the space in this file, by kind of content.")).color(t.text_muted));
|
||||
ui.add_space(8.0);
|
||||
// Every cell is laid out first so each column can be exactly as wide as its widest cell. A
|
||||
@@ -300,7 +310,9 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S
|
||||
let numbers = width(1) + width(2) + 2.0 * gap;
|
||||
let widths = [width(0).max(ui.available_width() - numbers), width(1), width(2)];
|
||||
let height = table.iter().flatten().map(|g| g.size().y).fold(ui.spacing().interact_size.y, f32::max);
|
||||
egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).show(ui, |ui| {
|
||||
// No minimum column width: egui's default widened a narrow Bytes column past `widths` and
|
||||
// pushed the table beyond the dialog's padding.
|
||||
egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
for row in table {
|
||||
for (column, (cell, width)) in row.into_iter().zip(widths).enumerate() {
|
||||
// Numbers line up on their last digit.
|
||||
@@ -311,13 +323,25 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let mut ok = false;
|
||||
ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked()));
|
||||
ok
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Measure the active document's space use, unless `space_audit` already describes it as
|
||||
/// it is now. With no document the audit is empty.
|
||||
pub(crate) fn refresh_space_audit(&mut self) {
|
||||
let Some((_, id)) = self.active_ids() else {
|
||||
self.space_audit.clear();
|
||||
self.space_audit_of = None;
|
||||
return;
|
||||
};
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let key = (id, doc.edit_generation());
|
||||
if self.space_audit_of != Some(key) {
|
||||
self.space_audit = doc.audit_space();
|
||||
self.space_audit_of = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimize PDF with the dialog's choices on a worker thread; the copy is saved when done.
|
||||
pub fn optimize_with_draft(&mut self) {
|
||||
let (settings, discard) = (self.optimize_draft.settings.clone(), self.optimize_draft.discard.clone());
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! The pointer as the pages see it, read once per frame from the page view's content widget.
|
||||
//!
|
||||
//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the
|
||||
//! chrome over a page scrolled under it) and which widget holds a drag (a panel's tab carried
|
||||
//! over them), and keeps a press with the widget it began on until release. egui's raw pointer
|
||||
//! state knows none of that, and its hover position is gone on the frame
|
||||
//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and
|
||||
//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's
|
||||
//! pointer positions or button states; a test below keeps it that way.
|
||||
//!
|
||||
//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet:
|
||||
//! the reading also takes whether one is open.
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, Rect};
|
||||
|
||||
/// A primary-button press or release, and where it happened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Edge {
|
||||
Press(Pos2),
|
||||
Release(Pos2),
|
||||
}
|
||||
|
||||
/// How many of a frame's presses and releases are kept. A frame holds one or two (a click, a
|
||||
/// double-click in a slow frame), but one after a stall can hold any number.
|
||||
const MAX_EDGES: usize = 8;
|
||||
|
||||
/// One frame's pointer, as the pages see it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct PagePointer {
|
||||
/// Where the pointer is, while the pages are what egui has under it and no other widget holds
|
||||
/// a drag (a panel's tab carried over them), or while they hold a press (until the frame of
|
||||
/// its release, past a page's edge included). With a dialog open only a drag the pages
|
||||
/// already have keeps it. It is egui's `interact_pos`, which still has the position on the
|
||||
/// frame a touch lifts.
|
||||
pub pos: Option<Pos2>,
|
||||
/// Where the press the pages hold began, also on the frame of its release when the press
|
||||
/// came in that same frame (egui has forgotten it by then). `None` for a press that began on
|
||||
/// something else.
|
||||
pub origin: Option<Pos2>,
|
||||
/// This frame's primary presses and releases of a press the pages hold, in order.
|
||||
pub edges: Vec<Edge>,
|
||||
/// The primary button is down, wherever it was pressed: a gesture ends once it is up.
|
||||
pub primary_down: bool,
|
||||
/// Some button is down, wherever it was pressed.
|
||||
pub any_down: bool,
|
||||
}
|
||||
|
||||
impl PagePointer {
|
||||
/// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while
|
||||
/// a dialog is open over the window or not (`modal`).
|
||||
pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self {
|
||||
let (at, origin, edges, primary_down, any_down) =
|
||||
ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down()));
|
||||
// egui's own answer to "do the pages hold a press?": from the press on until the frame
|
||||
// of its click, release or drag stop. A drag can outlive egui's press while the
|
||||
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
|
||||
// it stops: on the frame one opens egui still gives the pages a press that lands under
|
||||
// it, and its click.
|
||||
let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
|
||||
// `contains_pointer` alone also holds a few points under the edge of an Area over the
|
||||
// pages (egui's interact radius), so the top layer there must be the pages' own. A
|
||||
// dialog covers them from the frame it opens, before egui knows its layer.
|
||||
let layer = ui.layer_id();
|
||||
let on_top =
|
||||
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
|
||||
let pos = at.filter(|p| held || on_top(p));
|
||||
if !held {
|
||||
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
|
||||
}
|
||||
Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
|
||||
}
|
||||
|
||||
/// Where the pointer is, when it is inside `r`.
|
||||
pub(crate) fn within(&self, r: Rect) -> Option<Pos2> {
|
||||
self.pos.filter(|p| r.contains(*p))
|
||||
}
|
||||
|
||||
/// Where this frame's first primary press was.
|
||||
pub(crate) fn pressed(&self) -> Option<Pos2> {
|
||||
first_press(&self.edges)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's
|
||||
/// grip): egui hovers nothing else until it stops, though it still reports the pages under the
|
||||
/// pointer. A widget on the pages (a form field's editor) keeps them.
|
||||
fn dragged_elsewhere(ui: &egui::Ui, resp: &egui::Response) -> bool {
|
||||
let ctx = ui.ctx();
|
||||
ctx.dragged_id().is_some_and(|id| {
|
||||
id != resp.id && !ctx.read_response(id).is_some_and(|r| r.layer_id == resp.layer_id && resp.interact_rect.contains_rect(r.interact_rect))
|
||||
})
|
||||
}
|
||||
|
||||
fn first_press(edges: &[Edge]) -> Option<Pos2> {
|
||||
edges.iter().find_map(|e| match e {
|
||||
Edge::Press(p) => Some(*p),
|
||||
Edge::Release(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order.
|
||||
fn edges(events: &[Event]) -> Vec<Edge> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, .. } if pos.is_finite() => {
|
||||
Some(if *pressed { Edge::Press(*pos) } else { Edge::Release(*pos) })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.take(MAX_EDGES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Context, Modifiers, Sense, TouchDeviceId, TouchId, TouchPhase, pos2, vec2};
|
||||
|
||||
/// The pages' widget: x 0..200, y 0..200 of a 400×400 screen.
|
||||
const PAGES: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 200.0, y: 200.0 } };
|
||||
/// An Area drawn over the pages' lower right corner.
|
||||
const OVER: Rect = Rect { min: Pos2 { x: 120.0, y: 120.0 }, max: Pos2 { x: 260.0, y: 260.0 } };
|
||||
|
||||
fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn touch(phase: TouchPhase, pos: Pos2) -> Event {
|
||||
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None }
|
||||
}
|
||||
|
||||
/// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what
|
||||
/// the pages see.
|
||||
fn frame(ctx: &Context, events: Vec<Event>, area: bool) -> PagePointer {
|
||||
frame_with(ctx, events, area, false)
|
||||
}
|
||||
|
||||
/// [`frame`], with a dialog open (`modal`) whose layer egui has not met.
|
||||
fn frame_with(ctx: &Context, events: Vec<Event>, area: bool, modal: bool) -> PagePointer {
|
||||
let mut seen = PagePointer::default();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag());
|
||||
seen = PagePointer::read(ui, &resp, modal);
|
||||
if area {
|
||||
egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| {
|
||||
ui.allocate_exact_size(OVER.size(), Sense::hover());
|
||||
});
|
||||
}
|
||||
});
|
||||
// An input-only test has no renderer to take the font texture.
|
||||
output.textures_delta.clear();
|
||||
seen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() {
|
||||
let ctx = Context::default();
|
||||
let p = pos2(50.0, 60.0);
|
||||
frame(&ctx, vec![], false);
|
||||
let down = frame(&ctx, vec![touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, PointerButton::Primary, true)], false);
|
||||
assert_eq!((down.pos, down.origin, down.pressed()), (Some(p), Some(p), Some(p)));
|
||||
// egui-winit ends a touch with the release and `PointerGone` in one frame.
|
||||
let up = frame(&ctx, vec![touch(TouchPhase::End, p), button(p, PointerButton::Primary, false), Event::PointerGone], false);
|
||||
assert_eq!(ctx.input(|i| i.pointer.hover_pos()), None, "egui's hover position is gone");
|
||||
assert_eq!(up.pos, Some(p), "the pages still know where the finger lifted");
|
||||
assert_eq!(up.edges, [Edge::Release(p)]);
|
||||
assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_and_release_in_one_frame_keep_the_press() {
|
||||
let ctx = Context::default();
|
||||
let p = pos2(50.0, 60.0);
|
||||
frame(&ctx, vec![Event::PointerMoved(p)], false);
|
||||
let click = frame(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false);
|
||||
assert_eq!(ctx.input(|i| i.pointer.press_origin()), None, "egui has forgotten the press");
|
||||
assert_eq!(click.origin, Some(p));
|
||||
assert_eq!(click.edges, [Edge::Press(p), Edge::Release(p)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_drawn_over_the_pages_hides_the_pointer() {
|
||||
let ctx = Context::default();
|
||||
// The Area and the widget rects egui hit-tests against come from the frame before.
|
||||
frame(&ctx, vec![], true);
|
||||
frame(&ctx, vec![], true);
|
||||
let beside = pos2(100.0, 100.0);
|
||||
assert_eq!(frame(&ctx, vec![Event::PointerMoved(beside)], true).pos, Some(beside));
|
||||
// Inside the Area, and just inside its edge where egui's interact radius still finds the
|
||||
// pages' widget.
|
||||
for p in [pos2(160.0, 160.0), pos2(122.0, 150.0)] {
|
||||
assert_eq!(frame(&ctx, vec![Event::PointerMoved(p)], true).pos, None, "{p:?}");
|
||||
}
|
||||
// Off the pages altogether.
|
||||
assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None);
|
||||
// A press on the Area is not the pages' press, though the button is down all the same.
|
||||
let p = pos2(160.0, 160.0);
|
||||
let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true);
|
||||
assert_eq!(press, PagePointer { primary_down: true, any_down: true, ..PagePointer::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_the_pages_keeps_the_pointer_until_its_release() {
|
||||
let ctx = Context::default();
|
||||
frame(&ctx, vec![], true);
|
||||
frame(&ctx, vec![], true);
|
||||
let start = pos2(40.0, 40.0);
|
||||
frame(&ctx, vec![Event::PointerMoved(start), button(start, PointerButton::Primary, true)], true);
|
||||
// Dragged over the Area and off the pages: still theirs.
|
||||
for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] {
|
||||
let held = frame(&ctx, vec![Event::PointerMoved(p)], true);
|
||||
assert_eq!((held.pos, held.origin, held.primary_down), (Some(p), Some(start), true), "{p:?}");
|
||||
}
|
||||
let end = pos2(300.0, 60.0);
|
||||
let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true);
|
||||
assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too");
|
||||
assert!(!up.primary_down && !up.any_down, "and the button is up");
|
||||
assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_hides_the_pointer_and_its_clicks_but_not_a_drag_the_pages_have() {
|
||||
let ctx = Context::default();
|
||||
let p = pos2(50.0, 50.0);
|
||||
frame(&ctx, vec![Event::PointerMoved(p)], false);
|
||||
assert_eq!(frame(&ctx, vec![], false).pos, Some(p));
|
||||
assert_eq!(frame_with(&ctx, vec![], false, true).pos, None, "no pointer under a dialog, before egui knows its layer too");
|
||||
let click = frame_with(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false, true);
|
||||
assert_eq!((click.pos, click.origin, click.edges.as_slice()), (None, None, [].as_slice()), "nor a click egui gives the pages then");
|
||||
// A drag the pages already have when a dialog opens goes on until its release.
|
||||
frame(&ctx, vec![button(p, PointerButton::Primary, true)], false);
|
||||
frame(&ctx, vec![Event::PointerMoved(pos2(80.0, 50.0))], false);
|
||||
let q = pos2(110.0, 50.0);
|
||||
let dragging = frame_with(&ctx, vec![Event::PointerMoved(q)], false, true);
|
||||
assert_eq!((dragging.pos, dragging.origin), (Some(q), Some(p)), "a drag the pages have keeps its pointer under a dialog");
|
||||
let up = frame_with(&ctx, vec![button(q, PointerButton::Primary, false)], false, true);
|
||||
assert_eq!(up.edges.as_slice(), [Edge::Release(q)].as_slice(), "and its release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secondary_press_is_some_button_down_but_no_primary_edge() {
|
||||
let ctx = Context::default();
|
||||
let p = pos2(50.0, 60.0);
|
||||
frame(&ctx, vec![Event::PointerMoved(p)], false);
|
||||
let right = frame(&ctx, vec![button(p, PointerButton::Secondary, true)], false);
|
||||
assert_eq!((right.pos, right.any_down, right.primary_down, right.edges.len()), (Some(p), true, false, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_are_primary_finite_in_order_and_capped() {
|
||||
let p = pos2(1.0, 2.0);
|
||||
let q = pos2(3.0, 4.0);
|
||||
let events = vec![
|
||||
button(p, PointerButton::Secondary, true),
|
||||
button(p, PointerButton::Primary, true),
|
||||
Event::PointerMoved(q),
|
||||
button(pos2(f32::NAN, 0.0), PointerButton::Primary, false),
|
||||
button(q, PointerButton::Primary, false),
|
||||
];
|
||||
assert_eq!(edges(&events), [Edge::Press(p), Edge::Release(q)]);
|
||||
let many: Vec<Event> = (0..100).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect();
|
||||
assert_eq!(edges(&many).len(), MAX_EDGES);
|
||||
assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p));
|
||||
}
|
||||
|
||||
/// The page modules: the per-page chain in `canvas::document_area` and what it calls.
|
||||
const PAGE_MODULES: [&str; 18] = [
|
||||
"canvas.rs",
|
||||
"comments.rs",
|
||||
"forms_ui.rs",
|
||||
"prepare.rs",
|
||||
"fill_sign.rs",
|
||||
"sign_ui.rs",
|
||||
"crop.rs",
|
||||
"zoom_snap.rs",
|
||||
"camera_ui.rs",
|
||||
"measure_ui.rs",
|
||||
"redact_ui.rs",
|
||||
"link_ui.rs",
|
||||
"content_ui.rs",
|
||||
"edit_text_ui.rs",
|
||||
"object_ui.rs",
|
||||
"image_pick.rs",
|
||||
"signature_drag.rs",
|
||||
"calc_order_ui.rs",
|
||||
];
|
||||
|
||||
/// egui's raw pointer state: where the pointer is, and which buttons are down or went down.
|
||||
const RAW_READS: [&str; 21] = [
|
||||
"hover_pos()",
|
||||
"interact_pos()",
|
||||
"latest_pos()",
|
||||
"press_origin()",
|
||||
"pointer_hover_pos()",
|
||||
"pointer_interact_pos()",
|
||||
"pointer_latest_pos()",
|
||||
"primary_down()",
|
||||
"primary_pressed()",
|
||||
"primary_released()",
|
||||
"primary_clicked()",
|
||||
"any_down()",
|
||||
"any_pressed()",
|
||||
"any_released()",
|
||||
"any_click()",
|
||||
// These take the button, so they match up to the parenthesis.
|
||||
"button_down(",
|
||||
"button_pressed(",
|
||||
"button_released(",
|
||||
"button_clicked(",
|
||||
"button_double_clicked(",
|
||||
"button_triple_clicked(",
|
||||
];
|
||||
|
||||
/// Functions in the page modules that are not page input and read egui's pointer state
|
||||
/// themselves: (file, function, why).
|
||||
const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [
|
||||
("canvas.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"),
|
||||
("canvas.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"),
|
||||
("canvas.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"),
|
||||
("canvas.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"),
|
||||
("canvas.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"),
|
||||
("canvas.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"),
|
||||
("comments.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"),
|
||||
("forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"),
|
||||
("content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"),
|
||||
("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"),
|
||||
("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"),
|
||||
];
|
||||
|
||||
/// The function a `fn` line declares, laid out as rustfmt does (`pub`, `const` and the like
|
||||
/// before `fn`).
|
||||
fn declares(line: &str) -> Option<&str> {
|
||||
let mut rest = line;
|
||||
loop {
|
||||
let before = rest;
|
||||
for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] {
|
||||
rest = rest.strip_prefix(prefix).unwrap_or(rest);
|
||||
}
|
||||
if rest == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rest = rest.strip_prefix("fn ")?;
|
||||
rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end])
|
||||
}
|
||||
|
||||
/// Whether `line` calls `read`, and not a longer name that ends in it.
|
||||
fn reads(line: &str, read: &str) -> bool {
|
||||
line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_'))
|
||||
}
|
||||
|
||||
/// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is
|
||||
/// drawn over the pages and where a touch lifted, so a raw read in a page module brings back
|
||||
/// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing.
|
||||
#[test]
|
||||
fn page_modules_read_the_pointer_only_through_page_pointer() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut problems = Vec::new();
|
||||
let mut needed = vec![false; NOT_PAGE_INPUT.len()];
|
||||
for file in PAGE_MODULES {
|
||||
let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n");
|
||||
// Tests drive egui's input themselves.
|
||||
let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
// The top-level function, or method of an `impl`, that each line is in.
|
||||
let (mut function, mut in_impl) = (None, false);
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
if line.starts_with("impl") {
|
||||
(function, in_impl) = (None, true);
|
||||
} else if line == "}" {
|
||||
(function, in_impl) = (None, false);
|
||||
} else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) {
|
||||
function = Some(name);
|
||||
}
|
||||
if line.trim_start().starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for read in RAW_READS.into_iter().filter(|read| reads(line, read)) {
|
||||
match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) {
|
||||
Some(k) => needed[k] = true,
|
||||
None => problems.push(format!(
|
||||
"src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \
|
||||
(src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \
|
||||
lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.",
|
||||
n + 1
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) {
|
||||
if !needed {
|
||||
problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT."));
|
||||
}
|
||||
}
|
||||
// A module that takes the page pointer is a page module, so it is checked too.
|
||||
let mut dirs = vec![src.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in std::fs::read_dir(dir).expect("source directory") {
|
||||
let path = entry.expect("source entry").path();
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|ext| ext != "rs") {
|
||||
continue;
|
||||
}
|
||||
let file =
|
||||
path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
|
||||
let source = std::fs::read_to_string(&path).expect("source file");
|
||||
if file != "page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) {
|
||||
problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES."));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,14 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
/// A panel's title row: Back (with `back` as its tooltip) when given, the title and Close.
|
||||
/// Returns whether Back and Close were clicked.
|
||||
pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: Option<&str>) -> (bool, bool) {
|
||||
/// A panel's title row: a leading button (`lead`: its icon and tooltip) when given, the title
|
||||
/// and Close. Returns whether the leading button and Close were clicked.
|
||||
pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Option<(&str, &str)>) -> (bool, bool) {
|
||||
let mut go_back = false;
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(tip) = back
|
||||
&& icons::button(ui, "chevron-left", 26.0, false, tip).clicked()
|
||||
if let Some((icon, tip)) = lead
|
||||
&& icons::button(ui, icon, 26.0, false, tip).clicked()
|
||||
{
|
||||
go_back = true;
|
||||
}
|
||||
@@ -62,8 +62,14 @@ pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: Opt
|
||||
(go_back, close)
|
||||
}
|
||||
|
||||
/// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few
|
||||
/// tools until View more shows them all.
|
||||
fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let (_, close) = panel_header(ui, t, "All tools", None);
|
||||
// Labels distinct from the Combine grid's Expand and Collapse, which can show beside them.
|
||||
let (expand, close) = panel_header(ui, t, "All tools", Some(("maximize-2", tl!("Expand all tools"))));
|
||||
if expand {
|
||||
app.expand_all_tools();
|
||||
}
|
||||
if close {
|
||||
app.left_open = false;
|
||||
}
|
||||
@@ -154,7 +160,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
|
||||
edit_panel(app, ui, t, g);
|
||||
return;
|
||||
}
|
||||
let (back, close) = panel_header(ui, t, g.label, Some(tl!("Back to all tools")));
|
||||
let (back, close) = panel_header(ui, t, g.label, Some(("chevron-left", tl!("Back to all tools"))));
|
||||
if back {
|
||||
app.left = LeftPanel::AllTools;
|
||||
app.enter_mode(crate::Mode::AllTools);
|
||||
@@ -747,11 +753,22 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
RightPanel::Search => ("Search", None),
|
||||
RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())),
|
||||
};
|
||||
// Comments count like Acrobat (no replies); the replies go in small text below.
|
||||
let replies = if panel == RightPanel::Comments { crate::comments_panel::reply_count(info) } else { 0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5)));
|
||||
if let Some(c) = count {
|
||||
ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint));
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5)));
|
||||
if let Some(c) = count {
|
||||
ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
if replies > 0 {
|
||||
let text = crate::i18n::trn(crate::i18n::current(), replies as u64, "{n} reply", "{n} replies");
|
||||
ui.label(egui::RichText::new(text).font(theme::regular(11.0)).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
if panel == RightPanel::Pages && view.selected.len() > 1 {
|
||||
let n = view.selected.len().to_string();
|
||||
let picked = crate::i18n::fmt(tl!("{n} pages selected"), &[("n", &n)]);
|
||||
|
||||
@@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::comments;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
@@ -242,6 +243,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
form: &[FormField],
|
||||
@@ -250,7 +252,7 @@ pub(crate) fn page_input(
|
||||
view: &mut DocView,
|
||||
) -> Outcome {
|
||||
let mut out = Outcome { consumed: false, properties: false, placed: false, table: None };
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
|
||||
let pointer = ptr.pos;
|
||||
let prep = &mut view.prepare;
|
||||
|
||||
if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
@@ -259,7 +261,7 @@ pub(crate) fn page_input(
|
||||
return out;
|
||||
}
|
||||
if prep.ignore_release {
|
||||
if !ui.input(|i| i.pointer.primary_down()) {
|
||||
if !ptr.primary_down {
|
||||
prep.ignore_release = false;
|
||||
}
|
||||
out.consumed = true;
|
||||
@@ -271,7 +273,7 @@ pub(crate) fn page_input(
|
||||
&& gp == page
|
||||
{
|
||||
out.consumed = true;
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
prep.grab = None;
|
||||
let end = pointer.unwrap_or(start);
|
||||
match grab {
|
||||
@@ -337,7 +339,7 @@ pub(crate) fn page_input(
|
||||
if let Some(tool) = tool {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p);
|
||||
let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p);
|
||||
prep.grab = Some((page, Grab::Draw(origin), origin));
|
||||
out.consumed = true;
|
||||
if resp.clicked() {
|
||||
@@ -393,7 +395,7 @@ pub(crate) fn page_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
|
||||
prep.grab = Some((page, Grab::Corner(left, top), origin));
|
||||
out.consumed = true;
|
||||
@@ -538,10 +540,19 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String,
|
||||
}
|
||||
|
||||
/// Fields with their light-blue fill and name tags, the selection with its handles, and the
|
||||
/// rectangle being drawn or dragged.
|
||||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) {
|
||||
/// rectangle being drawn or dragged to the pages' `pointer`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn paint_page(
|
||||
ui: &egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
form: &[FormField],
|
||||
view: &DocView,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
let prep = &view.prepare;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let grab = prep.grab.filter(|(gp, ..)| *gp == page);
|
||||
let zoom = xf.zoom().clamp(0.5, 3.0);
|
||||
for f in form {
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS,
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
@@ -234,20 +235,22 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
|
||||
/// Redact tool input on a page. Presses on text are left to text selection (the selection is
|
||||
/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when
|
||||
/// the gesture is a box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
over_text: impl Fn(Pos2) -> bool,
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
|
||||
let pointer = ptr.pos;
|
||||
if let Some((dp, start)) = view.redact_drag
|
||||
&& dp == page
|
||||
{
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
view.redact_drag = None;
|
||||
let end = pointer.unwrap_or(start);
|
||||
let r = Rect::from_two_pos(start, end).intersect(xf.rect);
|
||||
@@ -263,7 +266,7 @@ pub(crate) fn page_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
if !over_text(origin) {
|
||||
view.redact_drag = Some((page, origin));
|
||||
return true;
|
||||
@@ -283,9 +286,9 @@ pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, vie
|
||||
}
|
||||
}
|
||||
|
||||
/// The box being drawn.
|
||||
pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) {
|
||||
if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos()))
|
||||
/// The box being drawn, to the pages' `pointer`.
|
||||
pub(crate) fn paint(painter: &egui::Painter, page: usize, view: &DocView, pointer: Option<Pos2>) {
|
||||
if let (Some((dp, start)), Some(p)) = (view.redact_drag, pointer)
|
||||
&& dp == page
|
||||
{
|
||||
let r = Rect::from_two_pos(start, p);
|
||||
|
||||
@@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu
|
||||
|
||||
use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind};
|
||||
use crate::fill_sign::SavedSig;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
|
||||
@@ -151,9 +152,16 @@ pub struct SignView {
|
||||
}
|
||||
|
||||
/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)).
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, info: &pdfcraft_render::DocInfo, view: &mut DocView) {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &pdfcraft_render::DocInfo,
|
||||
view: &mut DocView,
|
||||
) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@ impl SignatureDrag {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift));
|
||||
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending);
|
||||
let shift = painter.ctx().input(|i| i.modifiers.shift);
|
||||
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending);
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
|
||||
cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
match &layers.front {
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{PdfCraftApp, icons};
|
||||
|
||||
/// Horizontal and vertical inset of a single-line field.
|
||||
const FIELD_MARGIN: egui::Margin = egui::Margin::symmetric(10, 6);
|
||||
/// Where text starts inside a [`line`] or [`block`] field, so plain values beside fields can line up with it.
|
||||
pub(crate) const FIELD_TEXT_INSET: f32 = FIELD_MARGIN.left as f32;
|
||||
/// Outer height of a single-line field, including its inset.
|
||||
const FIELD_MIN_HEIGHT: f32 = 30.0;
|
||||
/// Padding inside every modal.
|
||||
|
||||
@@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
|
||||
use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::{PdfCraftApp, QuickTool};
|
||||
|
||||
/// A finished gesture: (page, the rectangle on screen, the same in page view points
|
||||
@@ -12,9 +13,8 @@ use crate::{PdfCraftApp, QuickTool};
|
||||
pub type Marquee = (usize, Rect, [f32; 4]);
|
||||
|
||||
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot).
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView) {
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
|
||||
@@ -1228,8 +1228,9 @@ fn the_panel_filters_and_sorts() {
|
||||
assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added");
|
||||
}
|
||||
|
||||
// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on
|
||||
// another page, with dates opposite to source order. An unnamed root has no thread.
|
||||
// Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page,
|
||||
// with dates opposite to source order) point at the first by reference. Nothing replies to the
|
||||
// unnamed root.
|
||||
const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
@@ -1250,7 +1251,7 @@ trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() {
|
||||
fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() {
|
||||
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.search = Some("needle".into());
|
||||
@@ -1272,14 +1273,16 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts()
|
||||
|
||||
h.state_mut().views[0].comments.hidden_authors.clear();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Duplicate root");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread");
|
||||
// The replies point at the first root: sharing its /NM doesn't give the other its thread
|
||||
// (or a search match on that thread).
|
||||
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 1);
|
||||
h.state_mut().views[0].comments.search = Some("status-only token".into());
|
||||
h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into());
|
||||
h.state_mut().views[0].comments.hidden_checks.push("Checked".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("First root");
|
||||
h.get_by_label("Duplicate root");
|
||||
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
|
||||
|
||||
h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into());
|
||||
h.run_steps(2);
|
||||
@@ -1293,7 +1296,167 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts()
|
||||
h.state_mut().views[0].comments.hidden_checks.clear();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Unnamed root");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root");
|
||||
}
|
||||
|
||||
// Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named
|
||||
// `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to.
|
||||
const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (Cover note) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (Second page note) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply on page two) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A2) /IRT 6 0 R /T (Beta) /Contents (Answer on page two) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn replies_follow_irt_references_when_names_repeat_on_every_page() {
|
||||
let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| app.set_option("panel", "comments").unwrap());
|
||||
let reply = h.get_by_label("Reply on page two");
|
||||
assert!(reply.rect().top() > h.get_by_label("Second page note").rect().top(), "the reply sits under page 2's comment");
|
||||
assert!(h.get_by_label("Page 2").rect().top() < reply.rect().top(), "not under page 1's comment, which shares the /NM");
|
||||
h.get_by_label("Answer on page two");
|
||||
// The header counts the two comments, not their replies (Acrobat says 2, not 4), and gives
|
||||
// the replies in small text under it.
|
||||
assert_eq!(h.query_all_by_label("4").count(), 0);
|
||||
h.get_by_label("2 replies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_names_no_replies_when_there_are_none() {
|
||||
let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.views[0].comments.search = Some("cover".into());
|
||||
});
|
||||
h.get_by_label("Cover note");
|
||||
h.get_by_label("2 replies"); // The header counts the document, not the search.
|
||||
let h = harness_with_bytes(SINGLE_COMMENT.to_vec(), |app| app.set_option("panel", "comments").unwrap());
|
||||
h.get_by_label("Only note");
|
||||
assert_eq!(h.query_all_by_label_contains("repl").count(), 0, "no \"0 replies\" line");
|
||||
}
|
||||
|
||||
// Contributor-original PDF: one comment, no replies.
|
||||
const SINGLE_COMMENT: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (only) /T (Alpha) /Contents (Only note) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
// Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply,
|
||||
// a Replace Text strikeout with its grouped caret, and a hidden status whose comment no page
|
||||
// lists. Page 2: an `/IRT` to the annotation itself, to an annotation no page lists (object 11),
|
||||
// to a pop-up, to a link and to a form field, and two annotations whose `/IRT`s point at each other.
|
||||
const THREAD_EDGES: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 19 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 20 0 R 21 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [60 100 160 112] /IT /StrikeOutTextEdit /T (Alpha) /Contents (Struck words) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Caret /Rect [158 96 166 104] /IRT 8 0 R /RT /Group /T (Alpha) /Contents (Replacement words) >> endobj
|
||||
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 10 0 R /T (Delta) /Contents (Points at itself) >> endobj
|
||||
11 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /T (Nobody) /Contents (Listed on no page) >> endobj
|
||||
12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj
|
||||
13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj
|
||||
14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj
|
||||
15 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj
|
||||
16 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (field) /Rect [200 60 260 80] >> endobj
|
||||
17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj
|
||||
18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj
|
||||
19 0 obj << /Type /Annot /Subtype /Text /Rect [240 20 260 40] /F 30 /IRT 11 0 R /T (Delta) /StateModel /Review /State /Accepted /Contents (Stray status) >> endobj
|
||||
20 0 obj << /Type /Annot /Subtype /Text /Rect [60 20 80 40] /IRT 21 0 R /T (Delta) /Contents (Loop one) >> endobj
|
||||
21 0 obj << /Type /Annot /Subtype /Text /Rect [90 20 110 40] /IRT 20 0 R /T (Delta) /Contents (Loop two) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() {
|
||||
let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.set_option("quick", "select").unwrap();
|
||||
});
|
||||
// An `/IRT` that reaches no listed comment leaves its annotation a comment of its own,
|
||||
// rather than a reply shown nowhere.
|
||||
// Links, form widgets and pop-ups are never parents: neither reader lists them as comments.
|
||||
for orphan in
|
||||
["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field", "Loop one", "Loop two"]
|
||||
{
|
||||
assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}");
|
||||
}
|
||||
// A reply to a reply joins the thread of the comment it starts from, also when that comment
|
||||
// has no /NM.
|
||||
let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply"));
|
||||
assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top());
|
||||
assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread");
|
||||
// Comments: the unnamed parent, the strikeout and the seven on page 2. Replies: the two
|
||||
// replies, not the Replace Text caret grouped with its strikeout.
|
||||
h.get_by_label("9");
|
||||
h.get_by_label("2 replies");
|
||||
// A status whose comment no page lists is not a comment: no card, and nothing to pick on the
|
||||
// page where it sits.
|
||||
assert_eq!(h.query_all_by_label("Stray status").count(), 0);
|
||||
click_pt(&mut h, (250.0, 30.0));
|
||||
assert_eq!(h.state().views[0].comments.selected, None);
|
||||
}
|
||||
|
||||
/// The arrow keys move a comment, not a reply: also not a reply to a comment with no `/NM`.
|
||||
#[test]
|
||||
fn arrow_keys_leave_a_reply_to_an_unnamed_comment_where_it_is() {
|
||||
let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("quick", "select").unwrap());
|
||||
let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().rect;
|
||||
let (reply, parent) = (rect(&h, "Direct reply"), rect(&h, "Unnamed parent"));
|
||||
h.state_mut().views[0].comments.selected = Some((0, 1));
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(3);
|
||||
assert_eq!(rect(&h, "Direct reply"), reply, "the reply stays");
|
||||
h.state_mut().views[0].comments.selected = Some((0, 0));
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(3);
|
||||
assert_ne!(rect(&h, "Unnamed parent"), parent, "its comment moves");
|
||||
}
|
||||
|
||||
// Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply.
|
||||
const CHECKED_REPLY: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (Unchecked comment) >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (Checked reply) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A checkmark or status belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2): one
|
||||
/// set on a reply doesn't mark the comment, on its card or in the page's menu.
|
||||
#[test]
|
||||
fn a_checkmark_on_a_reply_does_not_mark_its_comment() {
|
||||
let mut h = harness_with_bytes(CHECKED_REPLY.to_vec(), |app| {
|
||||
app.set_option("panel", "comments").unwrap();
|
||||
app.set_option("quick", "select").unwrap();
|
||||
});
|
||||
click_pt(&mut h, (110.0, 110.0));
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
|
||||
h.get_by_label("Checked reply");
|
||||
h.get_by_label("Mark with checkmark"); // The card's checkmark.
|
||||
assert_eq!(h.query_all_by_label("Accepted").count(), 0, "no status badge");
|
||||
let p = at(&h, 110.0, 110.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Copy text"); // The page's menu is open.
|
||||
assert_eq!(h.query_all_by_label("Remove checkmark").count(), 0);
|
||||
assert_eq!(h.query_all_by_label("Mark with checkmark").count(), 2, "the card and the page's menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2172,6 +2335,9 @@ fn markup_hover_harness(pdf: Vec<u8>, view_rotation: u16) -> Harness<'static, Pd
|
||||
app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
// Small enough to show the whole page in every rotation: a point hovered below the window
|
||||
// is not over the page.
|
||||
app.set_option("zoom", "100").unwrap();
|
||||
app.views[0].rotation = view_rotation;
|
||||
app
|
||||
});
|
||||
@@ -2193,7 +2359,9 @@ fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
|
||||
pw: page.width,
|
||||
ph: page.height,
|
||||
};
|
||||
xf.user_rect(info, 0, [x, y, x, y]).center()
|
||||
let p = xf.user_rect(info, 0, [x, y, x, y]).center();
|
||||
assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}");
|
||||
p
|
||||
};
|
||||
h.hover_at(p);
|
||||
h.run_steps(3);
|
||||
|
||||
@@ -6,11 +6,17 @@ use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let objs = [
|
||||
fixture_with_info(None)
|
||||
}
|
||||
|
||||
/// A one-page file, with `info` as its document information dictionary when given.
|
||||
fn fixture_with_info(info: Option<&str>) -> Vec<u8> {
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>",
|
||||
];
|
||||
objs.extend(info);
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
@@ -22,7 +28,8 @@ fn fixture() -> Vec<u8> {
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let info = if info.is_some() { " /Info 4 0 R" } else { "" };
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{info} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
@@ -96,3 +103,152 @@ fn document_properties_long_filename_does_not_overflow_card() {
|
||||
let card = card(&h, "dialog");
|
||||
assert!(card.width() <= 700.0, "dialog card should not blow out horizontally on long filename, width was: {}", card.width());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_properties_values_line_up_and_long_fields_wrap() {
|
||||
let info = "<< /Title (Showcase) /Subject (A specimen document exercising typography, world scripts, vector art, forms and annotations) /Creator (demo-pdf) >>";
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, fixture_with_info(Some(info))).unwrap();
|
||||
app.set_option("dialog", "properties").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let card = card(&h, "dialog");
|
||||
let title = h.get_by_role_and_label(Role::TextInput, "Title").rect();
|
||||
// The fields reach the card's padding instead of stopping short of it.
|
||||
assert!(card.right() - title.right() <= 24.0, "title field {title:?} in {card:?}");
|
||||
// Plain values start where the fields' text does.
|
||||
for value in ["doc.pdf", "demo-pdf"] {
|
||||
// The file name is also the document tab's label.
|
||||
let v = h.query_all_by_label(value).map(|n| n.rect()).find(|r| card.contains_rect(*r)).unwrap_or_else(|| panic!("{value} in the dialog"));
|
||||
assert!((v.left() - (title.left() + 10.0)).abs() <= 1.0, "{value} at {v:?}, title field {title:?}");
|
||||
}
|
||||
// A long subject wraps onto a second line rather than running past the field's edge.
|
||||
let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect();
|
||||
assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_tab_measures_the_file_and_stays_in_the_card() {
|
||||
use pdfcraft_ui_egui::{Dialog, PropsTab};
|
||||
let open = |dialog: Dialog| {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, fixture()).unwrap();
|
||||
app.dialog = Some(dialog);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
};
|
||||
let fonts = card(&open(Dialog::Properties(PropsTab::Fonts)), "dialog");
|
||||
// The tab is reachable by name, like the other Properties tabs.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, fixture()).unwrap();
|
||||
app.set_option("dialog", "space-audit").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Properties(PropsTab::SpaceAudit)));
|
||||
assert!(!h.state().space_audit.is_empty(), "the tab measures the file when it opens");
|
||||
h.get_by_label("Total");
|
||||
// Switching tabs keeps the dialog the same size, and the table stays inside its padding,
|
||||
// in the tab and in the Optimizer's own Audit Space Usage dialog.
|
||||
let tab = card(&h, "dialog");
|
||||
assert!((tab.width() - fonts.width()).abs() < 0.5, "space audit card {tab:?}, fonts card {fonts:?}");
|
||||
for h in [h, open(Dialog::AuditSpace)] {
|
||||
let card = card(&h, "dialog");
|
||||
let pct = h.get_by_label("Percentage").rect();
|
||||
assert!(pct.right() <= card.right() - 16.0, "Percentage {pct:?} in {card:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_tab_measures_again_after_an_edit() {
|
||||
use pdfcraft_engine::optimize::SpaceCategory;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, fixture()).unwrap();
|
||||
app.set_option("dialog", "space-audit").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let bookmarks =
|
||||
|h: &Harness<'_, PdfCraftApp>| h.state().space_audit.iter().filter(|r| r.category == SpaceCategory::Bookmarks).map(|r| r.bytes).sum::<u64>();
|
||||
let before = h.state().space_audit.clone();
|
||||
assert_eq!(bookmarks(&h), 0, "the fixture has no outline: {before:?}");
|
||||
// Further frames with the file unchanged keep the same measurement.
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().space_audit, before);
|
||||
// An edit while the tab is open is measured on the next frame.
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
|
||||
parent: vec![],
|
||||
index: 0,
|
||||
title: "Chapter".into(),
|
||||
page: 0,
|
||||
left: None,
|
||||
top: None,
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert!(bookmarks(&h) > 0, "the new outline is counted: {:?}", h.state().space_audit);
|
||||
// The table shows the new row (the rail's Bookmarks button has the same label).
|
||||
let card = card(&h, "dialog");
|
||||
assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_buttons_follow_the_content_after_a_taller_tab() {
|
||||
use pdfcraft_ui_egui::{Dialog, PropsTab};
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, fixture()).unwrap();
|
||||
app.dialog = Some(Dialog::Properties(PropsTab::InitialView));
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.state_mut().dialog = Some(Dialog::Properties(PropsTab::Description));
|
||||
h.run_steps(4);
|
||||
// The card shrinks back to the shorter tab: Close sits under the last row, not halfway
|
||||
// down the room the taller tab left behind.
|
||||
let last = h.get_by_label("PDF producer").rect();
|
||||
let close = h.get_by_role_and_label(Role::Button, "Close").rect();
|
||||
assert!(close.top() - last.bottom() <= 40.0, "Close {close:?} under the last row {last:?}");
|
||||
let card = card(&h, "dialog");
|
||||
assert!(card.bottom() - close.bottom() <= 24.0, "Close {close:?} at the foot of {card:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_hover_cursors_stay_off_while_a_dialog_is_open() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
// Small enough that the check box sits above the dialog card.
|
||||
app.set_option("zoom", "100").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
// The middle of the "agree" check box (/Rect [20 310 36 326] on a 300 × 400 page).
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let k = page.width() / 300.0;
|
||||
let check = page.min + egui::vec2(28.0 * k, (400.0 - 318.0) * k);
|
||||
h.hover_at(check);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the check box takes clicks");
|
||||
h.state_mut().set_option("dialog", "security").unwrap();
|
||||
h.run_steps(4);
|
||||
let card = card(&h, "dialog");
|
||||
assert!(!card.contains(check), "the check box {check:?} is beside the dialog {card:?}, under its backdrop");
|
||||
h.hover_at(check + egui::vec2(1.0, 0.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click
|
||||
//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside
|
||||
//! the page pointer, and close on a click away. On the frame egui reports a tap there is no
|
||||
//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge
|
||||
//! where the click landed by the click's own position.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
use crate::support::input::touch_tap;
|
||||
|
||||
/// A PDF of `objects`, numbered from 1 with the catalog first.
|
||||
fn pdf(objects: &[&str]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>";
|
||||
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
||||
|
||||
/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour`
|
||||
/// (Red, Green, Blue) 70 pt tall.
|
||||
fn form_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>",
|
||||
HELVETICA,
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with one line of 12 pt text at x 40, y 300.
|
||||
fn text_pdf() -> Vec<u8> {
|
||||
let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET";
|
||||
let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1);
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
content.as_str(),
|
||||
HELVETICA,
|
||||
])
|
||||
}
|
||||
|
||||
/// The app with `pdf` open at 150 % and its first frames run.
|
||||
fn open(pdf: Vec<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("test.pdf", None, pdf).unwrap();
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A mouse click: move, press and release in frames of their own, and the pointer stays there.
|
||||
/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.)
|
||||
fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Where field `name`'s widget is on screen.
|
||||
fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect {
|
||||
widget(h, name, 0)
|
||||
}
|
||||
|
||||
/// Where widget `w` of field `name` is on screen.
|
||||
fn widget(h: &Harness<'_, PdfCraftApp>, name: &str, w: usize) -> egui::Rect {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, w).expect("on screen")
|
||||
}
|
||||
|
||||
fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec<String> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
|
||||
}
|
||||
|
||||
/// The field whose editor or list is open.
|
||||
fn focus(h: &Harness<'_, PdfCraftApp>) -> Option<String> {
|
||||
h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone())
|
||||
}
|
||||
|
||||
/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset.
|
||||
fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 {
|
||||
let r = field(h, "colour");
|
||||
egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Form fields.
|
||||
|
||||
#[test]
|
||||
fn mouse_click_opens_a_combo_box() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(form_pdf());
|
||||
let p = field(&h, "country").center();
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_opens_a_combo_box_and_picks_a_choice() {
|
||||
let mut h = open(form_pdf());
|
||||
let p = field(&h, "country").center();
|
||||
touch_tap(&mut h, p);
|
||||
assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does");
|
||||
let france = h.get_by_label("France").rect().center();
|
||||
touch_tap(&mut h, france);
|
||||
assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_in_a_list_box_keeps_it_focused() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(form_pdf());
|
||||
let p = green(&h);
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row");
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_in_a_list_box_keeps_it_focused() {
|
||||
let mut h = open(form_pdf());
|
||||
let p = green(&h);
|
||||
touch_tap(&mut h, p);
|
||||
assert_eq!(
|
||||
(value(&h, "colour"), focus(&h).as_deref()),
|
||||
(vec!["Green".to_string()], Some("colour")),
|
||||
"a tap picks the row and keeps the list focused, as a click does"
|
||||
);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_inside_an_open_text_field_keeps_typing_there() {
|
||||
// A guard more than a probe: the text editor closes when egui takes its focus away, and
|
||||
// egui judges that from the tap's own position.
|
||||
let mut h = open(form_pdf());
|
||||
let r = field(&h, "name");
|
||||
touch_tap(&mut h, r.center());
|
||||
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing");
|
||||
touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0));
|
||||
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open");
|
||||
h.event(egui::Event::Text("Ada".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it");
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it.
|
||||
fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) {
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, 240.0, 160.0, 260.0],
|
||||
kind: pdfcraft_engine::NewField::Date,
|
||||
name: Some("due".into()),
|
||||
});
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Next month's calendar heading, and its 15th as the date field writes it.
|
||||
fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) {
|
||||
let (y, m, _) = h.state().session.today();
|
||||
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
|
||||
(format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_on_a_date_fields_calendar_keeps_it_open() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(form_pdf());
|
||||
add_date_field(&mut h);
|
||||
let (heading, picked) = next_month(&h);
|
||||
let p = field(&h, "due").center();
|
||||
mouse_click(&mut h, p);
|
||||
let next = h.get_by_label("›").rect().center();
|
||||
mouse_click(&mut h, next);
|
||||
assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open");
|
||||
h.get_by_label(&heading);
|
||||
let day = h.get_by_label("15").rect().center();
|
||||
mouse_click(&mut h, day);
|
||||
assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_on_a_date_fields_calendar_keeps_it_open() {
|
||||
let mut h = open(form_pdf());
|
||||
add_date_field(&mut h);
|
||||
let (heading, picked) = next_month(&h);
|
||||
let p = field(&h, "due").center();
|
||||
touch_tap(&mut h, p);
|
||||
let next = h.get_by_label("›").rect().center();
|
||||
touch_tap(&mut h, next);
|
||||
assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does");
|
||||
h.get_by_label(&heading);
|
||||
let day = h.get_by_label("15").rect().center();
|
||||
touch_tap(&mut h, day);
|
||||
assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it");
|
||||
}
|
||||
|
||||
/// A 300×400 page with a check box `agree` and a pair of radio buttons `pick`, A and then B to
|
||||
/// its right, each 20 pt square. A click focuses them, so the keys reach them (#2223).
|
||||
fn toggles_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [40 300 60 320] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 240 60 260] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 240 120 260] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream",
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_on_a_focused_check_box_keeps_its_focus() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(toggles_pdf());
|
||||
let p = field(&h, "agree").center();
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!((value(&h, "agree"), focus(&h).as_deref()), (vec!["Yes".to_string()], Some("agree")), "a click ticks and focuses the box");
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!(focus(&h).as_deref(), Some("agree"), "a click on the focused box keeps its focus");
|
||||
assert!(value(&h, "agree").is_empty(), "and clears it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_on_a_focused_check_box_keeps_its_focus() {
|
||||
let mut h = open(toggles_pdf());
|
||||
let p = field(&h, "agree").center();
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!(focus(&h).as_deref(), Some("agree"), "a click focuses the box");
|
||||
touch_tap(&mut h, p);
|
||||
assert_eq!(focus(&h).as_deref(), Some("agree"), "a tap on the focused box keeps its focus, as a click does");
|
||||
assert!(value(&h, "agree").is_empty(), "and clears it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_on_a_radio_button_keeps_the_group_focused() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(toggles_pdf());
|
||||
let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center());
|
||||
mouse_click(&mut h, a);
|
||||
assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["A".to_string()], Some("pick")), "a click selects A and focuses the group");
|
||||
mouse_click(&mut h, b);
|
||||
assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["B".to_string()], Some("pick")), "a click on B selects it in the focused group");
|
||||
h.key_press(egui::Key::ArrowLeft);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_on_a_radio_button_keeps_the_group_focused() {
|
||||
let mut h = open(toggles_pdf());
|
||||
let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center());
|
||||
mouse_click(&mut h, a);
|
||||
assert_eq!(focus(&h).as_deref(), Some("pick"), "a click focuses the group");
|
||||
touch_tap(&mut h, b);
|
||||
assert_eq!(focus(&h).as_deref(), Some("pick"), "a tap on B keeps the group focused, as a click does");
|
||||
assert_eq!(value(&h, "pick"), ["B"], "and selects B");
|
||||
h.key_press(egui::Key::ArrowLeft);
|
||||
h.run_steps(3);
|
||||
assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line
|
||||
// spacing list: a control that changes the paragraph on the click gives it the keyboard back in
|
||||
// that same frame, which hides a click away.
|
||||
|
||||
/// Opens the line of `text_pdf` for typing with Edit text.
|
||||
fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) {
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page on screen");
|
||||
let k = r.width() / 300.0;
|
||||
let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k);
|
||||
mouse_click(h, p);
|
||||
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
|
||||
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard");
|
||||
}
|
||||
|
||||
/// The open paragraph's line spacing, if a paragraph is open.
|
||||
fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option<f64> {
|
||||
h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_on_format_text_keeps_the_paragraph_open() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(text_pdf());
|
||||
edit_paragraph(&mut h);
|
||||
let spacing = h.get_by_value("Auto").rect().center();
|
||||
mouse_click(&mut h, spacing);
|
||||
assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open");
|
||||
let double = h.get_by_label("2").rect().center();
|
||||
mouse_click(&mut h, double);
|
||||
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_on_format_text_keeps_the_paragraph_open() {
|
||||
let mut h = open(text_pdf());
|
||||
edit_paragraph(&mut h);
|
||||
let spacing = h.get_by_value("Auto").rect().center();
|
||||
touch_tap(&mut h, spacing);
|
||||
assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does");
|
||||
let double = h.get_by_label("2").rect().center();
|
||||
touch_tap(&mut h, double);
|
||||
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
|
||||
}
|
||||
@@ -42,6 +42,19 @@ fn home_screen_links() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_community_card_closes_for_this_run() {
|
||||
let mut h = harness(|_| {});
|
||||
h.get_by_label("Join the ArtCraft community");
|
||||
h.get_by_label("Close").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().community_card_closed);
|
||||
assert!(h.query_by_label("Join the ArtCraft community").is_none());
|
||||
assert!(h.query_by_label("Join our Discord").is_none());
|
||||
// Not remembered: a fresh app shows the card again.
|
||||
assert!(!PdfCraftApp::new().community_card_closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn about_dialog_shows_the_brand_and_links() {
|
||||
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
|
||||
|
||||
@@ -39,6 +39,7 @@ mod date_format;
|
||||
mod dialog_chrome;
|
||||
mod document_links;
|
||||
mod editing;
|
||||
mod editor_taps;
|
||||
mod export;
|
||||
mod fill_sign;
|
||||
mod folders;
|
||||
@@ -57,6 +58,7 @@ mod pageboxes;
|
||||
mod panel_snapshots;
|
||||
mod pdf_links;
|
||||
mod pickers;
|
||||
mod pointer;
|
||||
mod preferences_layout;
|
||||
mod prepare;
|
||||
mod print;
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press
|
||||
//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a
|
||||
//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's
|
||||
//! cursor, tips or presses through.
|
||||
//!
|
||||
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor
|
||||
//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_engine::StampKind;
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
|
||||
|
||||
use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
|
||||
|
||||
/// A PDF of `objects`, numbered from 1 with the catalog first.
|
||||
fn pdf(objects: &[&str]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(content: &str) -> String {
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1)
|
||||
}
|
||||
|
||||
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
|
||||
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>";
|
||||
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
||||
/// One line of 12 pt Helvetica at x 40, y 300.
|
||||
const LINE: &str = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET";
|
||||
|
||||
/// One 300×400 page with a web link at x 50..250, y 300..350.
|
||||
fn link_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
CATALOG,
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with a paragraph at y≈300 and a link over its first words.
|
||||
fn linked_text_pdf() -> Vec<u8> {
|
||||
let text = stream("BT /F1 12 Tf 40 300 Td (Read the guide at example.org before you start.) Tj ET");
|
||||
pdf(&[
|
||||
CATALOG,
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [40 295 160 312] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
|
||||
text.as_str(),
|
||||
HELVETICA,
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it.
|
||||
fn comment_on_text_pdf() -> Vec<u8> {
|
||||
let text = stream(LINE);
|
||||
pdf(&[
|
||||
CATALOG,
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /Border [0 0 2] /F 4 /T (Reviewer) >>",
|
||||
text.as_str(),
|
||||
HELVETICA,
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with one line of text at x 40, y 300.
|
||||
fn text_pdf() -> Vec<u8> {
|
||||
let text = stream(LINE);
|
||||
pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", text.as_str(), HELVETICA])
|
||||
}
|
||||
|
||||
/// A 300×400 page with an unchecked check box `agree` at x 100..120, y 200..220.
|
||||
fn checkbox_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>",
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays
|
||||
/// open only where a test places the page relative to the window's chrome.
|
||||
fn open(pdf: Vec<u8>, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("test.pdf", None, pdf).unwrap();
|
||||
if !left_open {
|
||||
app.set_option("left", "closed").unwrap();
|
||||
}
|
||||
app.set_option("zoom", zoom).unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn stamp_tool(h: &mut Harness<'_, PdfCraftApp>) {
|
||||
h.state_mut().quick_tool = QuickTool::Stamp(StampKind::DynApproved);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn page(h: &Harness<'_, PdfCraftApp>) -> egui::Rect {
|
||||
h.state().views[0].page_screen_rect(0).expect("page on screen")
|
||||
}
|
||||
|
||||
/// The screen point at PDF (x, y) on the 300×400 page.
|
||||
fn at(h: &Harness<'_, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 {
|
||||
let r = page(h);
|
||||
let k = r.width() / 300.0;
|
||||
r.min + egui::vec2(x * k, (400.0 - y) * k)
|
||||
}
|
||||
|
||||
fn cursor_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon {
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
h.output().platform_output.cursor_icon
|
||||
}
|
||||
|
||||
/// A mouse click: press and release in frames of their own, as `drag_at` and `drop_at` send them.
|
||||
fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// A mouse drag from `from` by `by`, held over two frames on the way.
|
||||
fn mouse_drag(h: &mut Harness<'_, PdfCraftApp>, from: egui::Pos2, by: egui::Vec2) {
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
h.hover_at(from + by);
|
||||
h.run_steps(2);
|
||||
h.drop_at(from + by);
|
||||
for _ in 0..10 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
fn stamps(h: &Harness<'_, PdfCraftApp>) -> usize {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").count()
|
||||
}
|
||||
|
||||
fn square(h: &Harness<'_, PdfCraftApp>) -> Option<[f32; 4]> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Square").map(|a| a.rect)
|
||||
}
|
||||
|
||||
fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec<String> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone()
|
||||
}
|
||||
|
||||
/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from
|
||||
/// worker threads), and returns the last cursor seen.
|
||||
fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
|
||||
let mut cursor = egui::CursorIcon::Default;
|
||||
for _ in 0..50 {
|
||||
cursor = cursor_at(h, p);
|
||||
if want(cursor) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
cursor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The cursor promises one thing and a press does another.
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn stamp_tool_over_a_link_shows_its_crosshair() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
stamp_tool(&mut h);
|
||||
let on_link = at(&h, 150.0, 325.0);
|
||||
let off_link = at(&h, 150.0, 150.0);
|
||||
assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link");
|
||||
let cursor = cursor_at(&mut h, on_link);
|
||||
mouse_click(&mut h, on_link);
|
||||
assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link");
|
||||
assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn rectangle_tool_over_a_link_shows_its_crosshair() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||||
h.run_steps(4);
|
||||
let off_link = at(&h, 150.0, 150.0);
|
||||
let off = cursor_at(&mut h, off_link);
|
||||
let on_link = at(&h, 150.0, 325.0);
|
||||
let on = cursor_at(&mut h, on_link);
|
||||
assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn edit_text_over_a_linked_word_offers_the_move_arrow() {
|
||||
let mut h = open(linked_text_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::EditText;
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
// One paragraph, one line: a word outside the link, then a word inside it.
|
||||
let plain = at(&h, 230.0, 303.0);
|
||||
let linked = at(&h, 60.0, 303.0);
|
||||
let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move);
|
||||
let on = cursor_at(&mut h, linked);
|
||||
let id = h.state().views[0].id;
|
||||
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
|
||||
let k = page(&h).width() / 300.0;
|
||||
mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0));
|
||||
let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
|
||||
assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)");
|
||||
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}");
|
||||
assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
|
||||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||||
// Inside the rectangle where there is no text, and inside it over a word.
|
||||
let blank = at(&h, 120.0, 315.0);
|
||||
let on_text = at(&h, 60.0, 303.0);
|
||||
let over_blank = cursor_at(&mut h, blank);
|
||||
let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
|
||||
let before = square(&h).expect("the rectangle");
|
||||
let k = page(&h).width() / 300.0;
|
||||
mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0));
|
||||
let after = square(&h).expect("the rectangle");
|
||||
assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow");
|
||||
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}");
|
||||
assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too");
|
||||
}
|
||||
|
||||
/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points
|
||||
/// left of the edge. Returns the cursor shown at the press point and the line count after.
|
||||
fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) {
|
||||
let mut h = open(text_pdf(), "150", false);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
for _ in 0..20 {
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let id = h.state().views[0].id;
|
||||
let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
|
||||
let k = page(&h).width() / 300.0;
|
||||
let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0);
|
||||
let cursor = cursor_at(&mut h, grip);
|
||||
h.drag_at(grip);
|
||||
h.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(grip + egui::vec2(100.0 * k, 0.0));
|
||||
for _ in 0..10 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
(cursor, h.state().session.get(id).unwrap().text_lines(0).len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 3 of the page pointer router: one hit test"]
|
||||
fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() {
|
||||
let (inside_cursor, inside_lines) = narrow_from_left(1.0);
|
||||
assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal);
|
||||
assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph");
|
||||
let (outside_cursor, outside_lines) = narrow_from_left(5.0);
|
||||
assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out");
|
||||
assert!(outside_lines > 1, "so a drag from there rewraps it too");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Things drawn over the page keep the page's cursor, tips and presses off.
|
||||
|
||||
#[test]
|
||||
fn stamp_cursor_stays_off_the_command_palette() {
|
||||
let mut h = open(link_pdf(), "300", false);
|
||||
stamp_tool(&mut h);
|
||||
h.state_mut().palette_open = true;
|
||||
h.run_steps(4);
|
||||
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown");
|
||||
// On the palette's frame margin, where none of its widgets sets a cursor, over the page.
|
||||
let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0);
|
||||
assert!(page(&h).contains(p), "the palette must lie over the page here");
|
||||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
|
||||
// The page beside the palette still shows the tool.
|
||||
let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0);
|
||||
assert!(page(&h).contains(beside));
|
||||
assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair);
|
||||
}
|
||||
|
||||
/// A link beside the open palette shows no tip: tips draw above the palette.
|
||||
#[test]
|
||||
fn link_tip_stays_off_while_the_palette_is_open() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = open(link_pdf(), "300", false);
|
||||
h.state_mut().palette_open = true;
|
||||
h.run_steps(4);
|
||||
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown");
|
||||
let beside = [60.0, 100.0, 150.0, 200.0, 240.0]
|
||||
.into_iter()
|
||||
.flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y)))
|
||||
.find(|p| page(&h).contains(*p) && !palette.expand(40.0).contains(*p))
|
||||
.unwrap_or_else(|| panic!("no point of the link beside the palette {palette:?}, page {:?}", page(&h)));
|
||||
h.hover_at(beside);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("https://example.org/").is_none(), "no tip beside the palette");
|
||||
h.state_mut().palette_open = false;
|
||||
h.hover_at(beside);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("https://example.org/");
|
||||
}
|
||||
|
||||
/// A panel's tab carried over a link shows neither its tip nor its cursor, and the link answers
|
||||
/// again once the tab is let go.
|
||||
#[test]
|
||||
fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
h.state_mut().set_option("panel", "comments").unwrap();
|
||||
for _ in 0..4 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
let on_link = at(&h, 100.0, 340.0);
|
||||
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand);
|
||||
h.run_steps(2);
|
||||
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
|
||||
// Away from the link, then the Inspector's tab is picked up and carried over it.
|
||||
h.hover_at(at(&h, 150.0, 100.0));
|
||||
h.run_steps(4);
|
||||
let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center();
|
||||
h.hover_at(tab);
|
||||
h.run_steps(1);
|
||||
h.drag_at(tab);
|
||||
h.run_steps(1);
|
||||
for i in 1..=8 {
|
||||
h.hover_at(tab + (on_link - tab) * (i as f32 / 8.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
for _ in 0..4 {
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.dragged_id().is_some(), "the tab is still being dragged");
|
||||
assert!(!tip_shown(&h), "no link tip under a dragged panel tab");
|
||||
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
|
||||
}
|
||||
// Back to the tab and let go there, which leaves the layout as it was.
|
||||
for i in 1..=8 {
|
||||
h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.dragged_id().is_none());
|
||||
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "the link answers again");
|
||||
h.run_steps(2);
|
||||
assert!(tip_shown(&h), "and shows its tip");
|
||||
}
|
||||
|
||||
fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) {
|
||||
h.hover_at(egui::pos2(800.0, 500.0));
|
||||
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move });
|
||||
for _ in 0..10 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_cursor_stays_off_the_quick_bar() {
|
||||
let mut h = open(link_pdf(), "400", true);
|
||||
stamp_tool(&mut h);
|
||||
// Bring the page under the quick bar's left end.
|
||||
wheel(&mut h, egui::vec2(-200.0, 0.0));
|
||||
let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown");
|
||||
// Inside the bar's frame margin: no button there.
|
||||
let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0);
|
||||
assert!(page(&h).contains(p), "the quick bar must lie over the page here");
|
||||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
|
||||
let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0);
|
||||
assert!(page(&h).contains(below));
|
||||
assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() {
|
||||
let mut h = open(link_pdf(), "400", true);
|
||||
stamp_tool(&mut h);
|
||||
let top = page(&h).top();
|
||||
wheel(&mut h, egui::vec2(0.0, -300.0));
|
||||
let page = page(&h);
|
||||
// Above where the page started (the viewport's top edge, give or take its margin): the
|
||||
// toolbar, with the page scrolled up under it. Left of the link, which shows the hand.
|
||||
let x = page.left() + 20.0;
|
||||
let p = egui::pos2(x, top - 45.0);
|
||||
assert!(page.contains(p), "the page must extend under the chrome here");
|
||||
// The toolbar keeps its own cursor (a button's hand here); the page's is gone.
|
||||
assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
|
||||
assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair);
|
||||
}
|
||||
|
||||
/// Whether the page's hover tip was drawn in the last frame.
|
||||
fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool {
|
||||
let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover"));
|
||||
h.ctx.memory(|m| m.areas().is_visible(&tip))
|
||||
}
|
||||
|
||||
/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame.
|
||||
fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) {
|
||||
let doc = h.state().views[0].id;
|
||||
h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_tip_stays_under_the_unlock_prompt() {
|
||||
let mut h = open(link_pdf(), "300", true);
|
||||
let page = page(&h);
|
||||
let k = page.width() / 300.0;
|
||||
// Near the link's top left corner, clear of the prompt's card in the middle of the window.
|
||||
let on_link = page.min + egui::vec2(55.0 * k, (400.0 - 345.0) * k);
|
||||
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand);
|
||||
h.run_steps(2);
|
||||
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
|
||||
|
||||
open_unlock_prompt(&mut h);
|
||||
h.hover_at(on_link + egui::vec2(1.0, 0.0));
|
||||
for _ in 0..4 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock")));
|
||||
assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}");
|
||||
assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt");
|
||||
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_tip_lets_the_pointer_through_to_the_link() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let p = at(&h, 100.0, 340.0);
|
||||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
|
||||
let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip");
|
||||
// A quick move lands on the tip, drawn beside where the pointer was, and still on the link.
|
||||
let onto = tip.min + egui::vec2(6.0, 6.0);
|
||||
assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link");
|
||||
h.hover_at(onto);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link");
|
||||
}
|
||||
|
||||
// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet
|
||||
// and still gives the pages the pointer, and a press there.
|
||||
|
||||
#[test]
|
||||
fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let p = at(&h, 100.0, 340.0);
|
||||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
|
||||
h.run_steps(2);
|
||||
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
|
||||
open_unlock_prompt(&mut h);
|
||||
h.run_steps(1);
|
||||
assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
stamp_tool(&mut h);
|
||||
let p = at(&h, 150.0, 150.0);
|
||||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
|
||||
open_unlock_prompt(&mut h);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
one_frame(&mut h, [button(true), button(false)]);
|
||||
h.run_steps(4);
|
||||
assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() {
|
||||
let mut h = open(text_pdf(), "150", false);
|
||||
let word = at(&h, 50.0, 303.0);
|
||||
// The I-beam shows the page's text layer has arrived.
|
||||
assert_eq!(wait_for_cursor(&mut h, word, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page text is live");
|
||||
// Control: without a dialog, a double-click there selects the word.
|
||||
double_click(&mut h, word);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "control: a double-click selects the word");
|
||||
// A click on blank page clears it again.
|
||||
h.run_steps(30);
|
||||
let p = at(&h, 150.0, 150.0);
|
||||
mouse_click(&mut h, p);
|
||||
h.run_steps(30);
|
||||
assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "cleared before the dialog opens");
|
||||
h.state_mut().set_option("dialog", "shortcuts").unwrap();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().dialog.is_some(), "a modal dialog is open");
|
||||
// On the dialog's backdrop, where the page's first word lies underneath.
|
||||
double_click(&mut h, word);
|
||||
h.run_steps(4);
|
||||
let selected = h.state().views[0].selected_text();
|
||||
assert!(selected.as_deref().is_none_or(str::is_empty), "a double-click on a dialog selects nothing on the page under it: {selected:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Taps, touch drags and clicks within one frame. On the frame a touch (or a Windows pen) lifts,
|
||||
// egui-winit has sent `PointerGone`, so there is no hover position; egui still reports the click.
|
||||
|
||||
#[test]
|
||||
fn mouse_click_follows_a_link() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let p = at(&h, 150.0, 325.0);
|
||||
mouse_click(&mut h, p);
|
||||
assert!(h.state().pending_link.is_some(), "a click on a link asks to open it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_follows_a_link() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let p = at(&h, 150.0, 325.0);
|
||||
touch_tap(&mut h, p);
|
||||
assert!(h.state().pending_link.is_some(), "a tap on a link asks to open it, as a click does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_click_follows_a_link() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let p = at(&h, 150.0, 325.0);
|
||||
coalesced_click(&mut h, p);
|
||||
assert!(h.state().pending_link.is_some(), "a click whose press and release share a frame follows the link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_places_a_stamp() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
stamp_tool(&mut h);
|
||||
let p = at(&h, 150.0, 150.0);
|
||||
touch_tap(&mut h, p);
|
||||
assert_eq!(stamps(&h), 1, "a tap with the stamp tool places the stamp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_click_places_a_stamp() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
stamp_tool(&mut h);
|
||||
let p = at(&h, 150.0, 150.0);
|
||||
coalesced_click(&mut h, p);
|
||||
assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_ticks_a_check_box() {
|
||||
// Control for the tap below.
|
||||
let mut h = open(checkbox_pdf(), "150", false);
|
||||
let p = at(&h, 110.0, 210.0);
|
||||
mouse_click(&mut h, p);
|
||||
assert_eq!(agree(&h), ["Yes"], "a click ticks the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_tap_ticks_a_check_box() {
|
||||
let mut h = open(checkbox_pdf(), "150", false);
|
||||
let p = at(&h, 110.0, 210.0);
|
||||
touch_tap(&mut h, p);
|
||||
assert_eq!(agree(&h), ["Yes"], "a tap ticks the box, as a click does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_drag_moves_a_comment_and_leaves_no_gesture_behind() {
|
||||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||||
let k = page(&h).width() / 300.0;
|
||||
let before = square(&h);
|
||||
// A finger drags the rectangle 30 pt right, from a blank spot inside it.
|
||||
let from = at(&h, 120.0, 315.0);
|
||||
touch_drag(&mut h, from, from + egui::vec2(30.0 * k, 0.0));
|
||||
let after_touch = square(&h);
|
||||
assert_ne!(after_touch, before, "the touch drag moves the rectangle");
|
||||
// Later, a mouse drag on empty page far below it.
|
||||
let p = at(&h, 150.0, 100.0);
|
||||
mouse_drag(&mut h, p, egui::vec2(10.0 * k, 10.0 * k));
|
||||
assert_eq!(square(&h), after_touch, "an unrelated drag elsewhere leaves the rectangle where the touch put it");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Ending a gesture.
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 4 of the page pointer router: press-time capture and cancel"]
|
||||
fn escape_cancels_a_signature_rectangle() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::SignArea { certify: false };
|
||||
h.run_steps(4);
|
||||
let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0));
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.drag_at(a);
|
||||
h.run_steps(1);
|
||||
h.hover_at(b);
|
||||
h.run_steps(2);
|
||||
// Escape while the button is still held.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(4);
|
||||
let s = h.state();
|
||||
assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays");
|
||||
assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Pointer input in the frames the platform delivers it in.
|
||||
//!
|
||||
//! `Harness::step` runs one frame for every queued event, each 0.25 s after the last, so input
|
||||
//! sent through `Harness::event` (or `drag_at` and `drop_at`) never shares a frame. Real input
|
||||
//! does: egui-winit turns each touch (and on Windows each pen stroke, which winit reports as
|
||||
//! touch) into a `Touch` event plus pointer events in one frame, and sends `PointerGone` with
|
||||
//! every release, so `hover_pos()` is `None` on the frame egui reports the tap. A slow frame
|
||||
//! can also hold a whole click. These helpers push each frame's events into
|
||||
//! `Harness::input_mut` and step once.
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
|
||||
use egui_kittest::Harness;
|
||||
|
||||
/// Runs one frame with all of `events` in it, in order.
|
||||
pub fn one_frame<S>(h: &mut Harness<'_, S>, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn touch(phase: TouchPhase, pos: Pos2) -> Event {
|
||||
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None }
|
||||
}
|
||||
|
||||
/// A finger tap at `p`, or a pen tap on Windows: the touch starts (touch, move, press in one
|
||||
/// frame), and a frame later it ends (touch, release, `PointerGone` in one frame).
|
||||
pub fn touch_tap<S>(h: &mut Harness<'_, S>, p: Pos2) {
|
||||
one_frame(h, [touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, true)]);
|
||||
h.run_steps(1);
|
||||
one_frame(h, [touch(TouchPhase::End, p), button(p, false), Event::PointerGone]);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// A finger drag from `a` to `b` in six moves, one frame each, ending as a touch does: release
|
||||
/// and `PointerGone` in one frame.
|
||||
pub fn touch_drag<S>(h: &mut Harness<'_, S>, a: Pos2, b: Pos2) {
|
||||
one_frame(h, [touch(TouchPhase::Start, a), Event::PointerMoved(a), button(a, true)]);
|
||||
for i in 1..=6 {
|
||||
let p = a + (b - a) * (i as f32 / 6.0);
|
||||
one_frame(h, [touch(TouchPhase::Move, p), Event::PointerMoved(p)]);
|
||||
}
|
||||
one_frame(h, [touch(TouchPhase::End, b), button(b, false), Event::PointerGone]);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// A mouse click at `p` whose press and release land in one slow frame (#2100).
|
||||
pub fn coalesced_click<S>(h: &mut Harness<'_, S>, p: Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
one_frame(h, [button(p, true), button(p, false)]);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// A double-click at `p`: two clicks, each a press and release in one frame, in consecutive
|
||||
/// frames (0.25 s apart, within egui's 0.3 s double-click delay).
|
||||
pub fn double_click<S>(h: &mut Harness<'_, S>, p: Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
one_frame(h, [button(p, true), button(p, false)]);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! State-based waits for native preparation, which completes between UI frames.
|
||||
//! State-based waits for native preparation, which completes between UI frames, and pointer
|
||||
//! input in the frames the platform delivers it in ([`input`]).
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
pub mod input;
|
||||
|
||||
/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work
|
||||
/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is
|
||||
/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between
|
||||
|
||||
@@ -125,6 +125,170 @@ fn every_catalog_tool_is_listed_after_view_more() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_all_tools_lists_every_tool_by_category_and_searches() {
|
||||
let mut h = harness(|_| {});
|
||||
h.get_by_label("Expand all tools").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().all_tools_full);
|
||||
for c in pdfcraft_engine::catalog::TOOL_CATEGORIES {
|
||||
h.get_by_label(c.label);
|
||||
}
|
||||
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
assert!(h.query_all_by_label(g.label).count() >= 1, "tool {} missing from the expanded view", g.label);
|
||||
}
|
||||
// An item name finds its tool; the other categories drop out.
|
||||
h.state_mut().all_tools_query = "rotate".into();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.query_all_by_label("Organize pages").count(), 1, "only the expanded view's tile; the sidebar is gone");
|
||||
assert!(h.query_by_label("Forms & Signatures").is_none());
|
||||
h.state_mut().all_tools_query = "no such tool".into();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("No tools match your search.");
|
||||
// Choosing a tool goes back to the workspace with that tool's panel.
|
||||
h.state_mut().all_tools_query = "organize".into();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Organize pages").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full);
|
||||
assert_eq!(h.state().left, pdfcraft_ui_egui::LeftPanel::Tool("organize"));
|
||||
h.get_by_label("Back to all tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_and_escape_close_the_expanded_all_tools() {
|
||||
let mut h = harness(|app| app.set_option("tools", "full").unwrap());
|
||||
h.get_by_label("Collapse all tools").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full);
|
||||
h.get_by_label("View more");
|
||||
h.get_by_label("Expand all tools").click();
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_in_a_dialog_over_the_expanded_all_tools_leaves_the_view_open() {
|
||||
let mut h = harness(|app| app.set_option("tools", "full").unwrap());
|
||||
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About);
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().all_tools_full, "Escape belonged to the dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_in_the_command_palette_over_the_expanded_all_tools_leaves_the_view_open() {
|
||||
let mut h = harness(|app| app.set_option("tools", "full").unwrap());
|
||||
h.state_mut().palette_open = true;
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().palette_open, "Escape closed the palette");
|
||||
assert!(h.state().all_tools_full, "and only the palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_workspace_from_the_mode_option_puts_the_expanded_all_tools_away() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("tools", "full").unwrap();
|
||||
app.set_option("mode", "all").unwrap();
|
||||
assert!(app.all_tools_full, "All tools itself keeps the view");
|
||||
app.set_option("mode", "read").unwrap();
|
||||
app.set_option("mode", "all").unwrap();
|
||||
assert!(!app.all_tools_full, "coming back shows the sidebar, as the mode bar does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_document_puts_the_expanded_all_tools_away() {
|
||||
use pdfcraft_ui_egui::Mode;
|
||||
// All tools as the default workspace: no workspace change, but the document must show.
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("tools", "full").unwrap();
|
||||
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
|
||||
assert!(!app.all_tools_full, "the document shows, not the catalogue over it");
|
||||
// Read as the default workspace: coming back to All tools later shows the sidebar.
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-mode", "read").unwrap();
|
||||
app.set_option("tools", "full").unwrap();
|
||||
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
|
||||
assert_eq!(app.mode, Mode::Read);
|
||||
assert!(!app.all_tools_full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_tool_puts_the_expanded_all_tools_away() {
|
||||
use pdfcraft_ui_egui::LeftPanel;
|
||||
let mut h = harness(|app| app.set_option("tools", "full").unwrap());
|
||||
h.state_mut().set_option("tool", "organize").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full, "the tool's panel shows, not the catalogue");
|
||||
assert_eq!(h.state().left, LeftPanel::Tool("organize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_tab_coming_forward_puts_the_expanded_all_tools_away() {
|
||||
// Home, through the control option.
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.state_mut().set_option("tools", "full").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().all_tools_full, "the document's own tab keeps it");
|
||||
h.state_mut().set_option("home", "").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full);
|
||||
h.get_by_label_contains("Welcome to PdfCraft");
|
||||
// Another document's tab.
|
||||
let mut h = harness(|app| {
|
||||
app.open_bytes("one.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
|
||||
app.open_bytes("two.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
|
||||
});
|
||||
h.state_mut().set_option("tools", "full").unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full, "one.pdf shows, not the catalogue over it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_home_with_no_document_puts_the_expanded_all_tools_away() {
|
||||
// Home is already the tab in front, so only the option itself can put the view away.
|
||||
let mut h = harness(|app| app.set_option("tools", "full").unwrap());
|
||||
h.state_mut().set_option("home", "").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().all_tools_full);
|
||||
h.get_by_label_contains("Welcome to PdfCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_all_tools_from_read_mode_restores_the_right_panel() {
|
||||
use pdfcraft_ui_egui::{Mode, RightPanel};
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.state_mut().right = Some(RightPanel::Bookmarks);
|
||||
h.state_mut().execute("view.read_mode");
|
||||
h.run_steps(3);
|
||||
assert_eq!((h.state().mode, h.state().right), (Mode::Read, None), "Read mode hides the panel");
|
||||
h.state_mut().set_option("tools", "full").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().all_tools_full);
|
||||
assert_eq!((h.state().mode, h.state().right), (Mode::AllTools, Some(RightPanel::Bookmarks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_all_tools_from_edit_puts_edit_text_down() {
|
||||
use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool};
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
h.get_by_label("Edit").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::EditText);
|
||||
h.state_mut().set_option("tools", "full").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().all_tools_full);
|
||||
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::AllTools, QuickTool::Select));
|
||||
assert_eq!(h.state().left, LeftPanel::AllTools, "collapsing goes back to the All tools list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_pdf_shows_comments_and_bookmarks() {
|
||||
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
|
||||
|
||||
@@ -94,7 +94,7 @@ fn pdf(objs: Vec<String>) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Named roots exercise thread lookups even when none of the comments has replies.
|
||||
/// Named roots, none with replies: the panel still looks up every root's thread.
|
||||
fn named_comments(n: usize) -> Vec<u8> {
|
||||
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
|
||||
let mut objs = vec![
|
||||
|
||||
+211
-4
@@ -12,7 +12,7 @@ use std::collections::HashMap;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::model::{Field, Node, SomIndices, Template};
|
||||
use crate::model::{Field, Node, SomIndices, Template, Ui};
|
||||
use crate::packets::{Encoding, encode};
|
||||
|
||||
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
|
||||
@@ -1630,11 +1630,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
|
||||
}
|
||||
|
||||
/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream
|
||||
/// `reuse`, else as a new object.
|
||||
/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object.
|
||||
fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option<ObjRef>, bytes: &[u8]) -> ObjRef {
|
||||
let stream = Object::Stream(Stream::flate(Dict::new(), bytes));
|
||||
match (old.and_then(Object::as_ref), reuse) {
|
||||
(Some(r), Some(keep)) if r == keep => {
|
||||
match old.and_then(Object::as_ref) {
|
||||
Some(r) if reuse == Some(r) || doc.is_edited(r.num) => {
|
||||
doc.set(r, stream);
|
||||
r
|
||||
}
|
||||
@@ -1794,3 +1794,210 @@ pub fn is_on(text: &str, on: &str) -> bool {
|
||||
}
|
||||
!matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty()
|
||||
}
|
||||
|
||||
/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or
|
||||
/// the single `/XFA` stream), when `doc` has one.
|
||||
fn datasets_stream_ref(doc: &Document) -> Option<ObjRef> {
|
||||
let root = doc.root()?;
|
||||
let catalog = doc.get(root);
|
||||
let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?;
|
||||
let xfa = acro.as_dict()?.get(b"XFA")?;
|
||||
match &*doc.resolve(xfa) {
|
||||
Object::Array(items) => {
|
||||
let at = (0..items.len())
|
||||
.step_by(2)
|
||||
.find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?;
|
||||
items.get(at + 1)?.as_ref()
|
||||
}
|
||||
Object::Stream(_) => xfa.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14,
|
||||
/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm
|
||||
/// dictionary.
|
||||
fn acro_password_fields(doc: &Document) -> Vec<(String, String)> {
|
||||
const PASSWORD_FLAG: i64 = 1 << 13;
|
||||
let Some(root) = doc.root() else { return Vec::new() };
|
||||
let catalog = doc.get(root);
|
||||
let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() };
|
||||
let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
struct AcroWalk<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
out: Vec<(String, String)>,
|
||||
}
|
||||
impl AcroWalk<'_> {
|
||||
fn walk(
|
||||
&mut self,
|
||||
items: &[Object],
|
||||
parent_name: &str,
|
||||
inherited_ft: Option<&[u8]>,
|
||||
inherited_ff: i64,
|
||||
inherited_dv: Option<&str>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
for item in items {
|
||||
let Some(r) = item.as_ref() else { continue };
|
||||
if !self.seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let obj = self.doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let ft = d.name(b"FT").or(inherited_ft);
|
||||
let ff = d.int(b"Ff").unwrap_or(inherited_ff);
|
||||
let dv_own = text_of(self.doc, d, b"DV");
|
||||
let dv = dv_own.as_deref().or(inherited_dv);
|
||||
let t = text_of(self.doc, d, b"T");
|
||||
let full_name = match (&t, parent_name.is_empty()) {
|
||||
(Some(seg), true) => seg.clone(),
|
||||
(Some(seg), false) => format!("{parent_name}.{seg}"),
|
||||
(None, _) => parent_name.to_string(),
|
||||
};
|
||||
let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T")));
|
||||
if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 {
|
||||
let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone());
|
||||
if !som.is_empty() {
|
||||
self.out.push((som, dv.unwrap_or_default().to_string()));
|
||||
}
|
||||
}
|
||||
if !kids.is_empty() {
|
||||
self.walk(&kids, &full_name, ft, ff, dv, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() };
|
||||
w.walk(&top, "", None, 0, None, 0);
|
||||
w.out
|
||||
}
|
||||
|
||||
/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their
|
||||
/// defaults (or empty when they have no default).
|
||||
fn password_dataset_wants(doc: &Document) -> Vec<Want> {
|
||||
if datasets_stream_ref(doc).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() };
|
||||
let Some(data) = parse_datasets(&packet) else { return Vec::new() };
|
||||
with_template(doc, None, |tpl| {
|
||||
let mut candidates: Vec<(String, DataPath, String)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Some(t) = tpl {
|
||||
struct Walk<'a> {
|
||||
tpl: &'a Template,
|
||||
data: &'a DataNode,
|
||||
nodes: usize,
|
||||
seen: &'a mut std::collections::HashSet<DataPath>,
|
||||
out: &'a mut Vec<(String, DataPath, String)>,
|
||||
}
|
||||
impl Walk<'_> {
|
||||
fn child_som(parent: &str, name: Option<&str>, index: usize) -> String {
|
||||
match name {
|
||||
Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"),
|
||||
Some(n) => format!("{n}[{index}]"),
|
||||
None => parent.to_string(),
|
||||
}
|
||||
}
|
||||
fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) {
|
||||
if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES {
|
||||
return;
|
||||
}
|
||||
let sibs = SomIndices::of(children);
|
||||
for (i, n) in children.iter().enumerate() {
|
||||
if self.nodes >= crate::script::MAX_FORM_NODES {
|
||||
break;
|
||||
}
|
||||
self.nodes += 1;
|
||||
let sib = sibs.at(i);
|
||||
match n {
|
||||
Node::Subform(sf) => {
|
||||
let from_data = match &sf.common.name {
|
||||
Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name),
|
||||
_ => 0,
|
||||
};
|
||||
let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data);
|
||||
for k in 0..instances {
|
||||
if k > 0 {
|
||||
if self.nodes >= crate::script::MAX_FORM_NODES {
|
||||
break;
|
||||
}
|
||||
self.nodes += 1;
|
||||
}
|
||||
let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k);
|
||||
self.walk(&sf.children, &som, depth + 1);
|
||||
}
|
||||
}
|
||||
Node::Area(a) => {
|
||||
let som = Self::child_som(parent_som, a.common.name.as_deref(), sib);
|
||||
self.walk(&a.children, &som, depth + 1);
|
||||
}
|
||||
Node::Field(f) if f.ui == Ui::PasswordEdit => {
|
||||
let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib);
|
||||
let path = data_path(self.tpl, &som);
|
||||
if !path.is_empty() && self.seen.insert(path.clone()) {
|
||||
let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default();
|
||||
self.out.push((som, path, default));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = Node::Subform(Box::new(t.root.clone()));
|
||||
let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates };
|
||||
w.walk(std::slice::from_ref(&root), "", 0);
|
||||
if let Some(ps) = &t.root.page_set {
|
||||
for (i, area) in ps.areas.iter().enumerate() {
|
||||
let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
|
||||
w.walk(&area.items, &som, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (som, default) in acro_password_fields(doc) {
|
||||
let path = path_of(tpl, &som);
|
||||
if !path.is_empty() && seen.insert(path.clone()) {
|
||||
candidates.push((som, path, default));
|
||||
}
|
||||
}
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|(som, path, default)| {
|
||||
let cur = data.value_at(&path)?;
|
||||
(!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any XFA password field (`<passwordEdit/>` or an AcroForm text field with the Password
|
||||
/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944).
|
||||
pub fn has_password_values(doc: &Document) -> bool {
|
||||
!password_dataset_wants(doc).is_empty()
|
||||
}
|
||||
|
||||
/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default
|
||||
/// where the field has one, else emptying the element), replacing the datasets stream in place
|
||||
/// (#3944). Returns how many password fields were cleared in the datasets.
|
||||
pub fn clear_password_values(doc: &mut Document) -> Result<usize, XfaError> {
|
||||
let wants = password_dataset_wants(doc);
|
||||
if wants.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = wants.len();
|
||||
let reuse = datasets_stream_ref(doc);
|
||||
let report = rewrite(doc, &[Op::Values(&wants)], reuse)?;
|
||||
if !report.written || !password_dataset_wants(doc).is_empty() {
|
||||
let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str);
|
||||
return Err(XfaError::Malformed(reason.to_string()));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user