mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
Merge remote-tracking branch 'upstream/main' into fix/xfa-required-fields
# Conflicts: # crates/xfa/README.md # parity/acrobat-features.toml
This commit is contained in:
+35
-34
@@ -4,37 +4,38 @@
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (288)
|
||||
## In this repository (289)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -285,17 +286,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
|
||||
## Compiled in through dependencies (5)
|
||||
|
||||
|
||||
+43
-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,17 +206,27 @@ usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ckb.tsv"
|
||||
title = "Central Kurdish (Sorani) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fa.tsv"
|
||||
title = "Persian (Farsi) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -226,7 +236,7 @@ usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -236,7 +246,7 @@ usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/hi.tsv"
|
||||
title = "Hindi interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -245,7 +255,7 @@ usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/id.tsv"
|
||||
title = "Indonesian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -254,7 +264,7 @@ usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ko.tsv"
|
||||
title = "Korean interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -264,7 +274,7 @@ usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ro.tsv"
|
||||
title = "Romanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -275,7 +285,7 @@ usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/fi.tsv"
|
||||
title = "Finnish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3396,7 +3406,7 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3406,7 +3416,7 @@ usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3428,7 +3438,7 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
path = "crates/ui-egui/src/i18n/nb.tsv"
|
||||
title = "Norwegian Bokmål interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3482,7 +3492,7 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
path = "crates/ui-egui/src/i18n/sq.tsv"
|
||||
title = "Albanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3492,7 +3502,7 @@ usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/th.tsv"
|
||||
title = "Thai interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
@@ -3502,7 +3512,7 @@ usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/ca.tsv"
|
||||
title = "Catalan interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+36
-36
@@ -3934,7 +3934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3972,7 +3972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3983,7 +3983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3994,7 +3994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4016,7 +4016,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4034,11 +4034,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4046,7 +4046,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4077,7 +4077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4089,7 +4089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4104,7 +4104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4145,14 +4145,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4162,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4172,7 +4172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4185,11 +4185,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4197,7 +4197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4210,14 +4210,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4227,7 +4227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4242,11 +4242,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4267,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4278,7 +4278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4290,7 +4290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4306,7 +4306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4325,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4361,11 +4361,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
@@ -4413,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4425,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4439,7 +4439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+34
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.13.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.13.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.13.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.13.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.13.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.13.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.13.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
@@ -225,6 +226,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
|
||||
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
|
||||
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged.
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
|
||||
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
|
||||
@@ -318,6 +320,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<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();
|
||||
|
||||
@@ -188,8 +188,10 @@ impl Automation {
|
||||
i.flip_v = !i.flip_v;
|
||||
}
|
||||
if let Some(c) = rect_arg(a, "crop")? {
|
||||
if !c.iter().all(|v| (0.0..0.5).contains(v)) {
|
||||
return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)"));
|
||||
if !pdfcraft_engine::AddedImage::crop_valid(c) {
|
||||
return Err(bad(
|
||||
"crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95",
|
||||
));
|
||||
}
|
||||
i.crop = c;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,12 @@ impl Automation {
|
||||
self
|
||||
}
|
||||
|
||||
/// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`].
|
||||
pub fn with_xfa_time_scale(mut self, factor: u32) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// The clock and time zone of a host without its own. See [`Session::with_wall_clock`].
|
||||
pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset);
|
||||
|
||||
@@ -60,7 +60,10 @@ fn color() -> Value {
|
||||
fn comment_ref(mut extra: Value) -> Value {
|
||||
if let Some(props) = extra.as_object_mut() {
|
||||
props.insert("doc".into(), doc());
|
||||
props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." }));
|
||||
props.insert(
|
||||
"id".into(),
|
||||
json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }),
|
||||
);
|
||||
props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." }));
|
||||
props.insert(
|
||||
"index".into(),
|
||||
@@ -624,7 +627,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
"image": { "type": "string", "description": "Replace the picture with this file." },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
@@ -700,7 +703,7 @@ pub fn tools() -> Vec<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(
|
||||
@@ -953,7 +956,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_header_footer",
|
||||
"Add header and footer",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
)
|
||||
.cmd("edit.header_footer")
|
||||
.with(schema(
|
||||
|
||||
@@ -252,6 +252,19 @@ fn doc_info_reports_the_open_destination() {
|
||||
"{open}"
|
||||
);
|
||||
assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0));
|
||||
// The initial view reads the named destination's page, and choosing page 1 replaces it (ISO
|
||||
// 32000-2 §12.3.2.4): one undo step, kept by the save.
|
||||
let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone();
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2);
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1);
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 2);
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page(&mut a, saved), 1);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null());
|
||||
}
|
||||
@@ -2121,6 +2134,83 @@ fn nested_replies_stay_in_the_listing() {
|
||||
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_ids_repeat_across_pages() {
|
||||
let dir = workdir("repeated-ids");
|
||||
// Contributor-original PDF, shaped like PyMuPDF output.
|
||||
std::fs::write(
|
||||
dir.join("fitz.pdf"),
|
||||
b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap();
|
||||
let replies = |a: &mut Automation| -> Vec<(String, Vec<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");
|
||||
@@ -4394,6 +4484,12 @@ fn adding_content_through_tools() {
|
||||
assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}");
|
||||
// Image tools: rotate, flip, crop, replace.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] }));
|
||||
// One side may trim most of the picture; together an axis keeps at least 5 %.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] }));
|
||||
assert!(matches!(
|
||||
a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" }));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true);
|
||||
assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_))));
|
||||
@@ -5765,6 +5861,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
|
||||
/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
|
||||
/// signed file reopens with the signature intact and on that field.
|
||||
#[test]
|
||||
fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
|
||||
let dir = workdir("xfa-sign");
|
||||
std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let som = "form[0].page1[0].sign[0]";
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}");
|
||||
ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" }));
|
||||
let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": signed }));
|
||||
let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}"));
|
||||
assert_eq!(s["field"], som, "{s}");
|
||||
assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
@@ -6771,12 +6889,15 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// Hundreds of calculations run on opening and again after a change through the tools. The
|
||||
/// time limits are raised so that a busy machine does not stop them (the engine's tests check
|
||||
/// that the limits stop them).
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() {
|
||||
let dir = workdir("xfa-many");
|
||||
let (sections, rows) = (10, 20);
|
||||
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let started = std::time::Instant::now();
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
|
||||
let open_took = started.elapsed();
|
||||
@@ -7199,7 +7320,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
let dir = workdir("xfa-print-buttons");
|
||||
let template = pdfcraft_xfa::fixtures::repeated_print_button_template();
|
||||
std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the click scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap();
|
||||
let check = |a: &mut Automation, doc| {
|
||||
let fields = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
@@ -7224,7 +7346,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
|
||||
let field = |a: &mut Automation, n: &str| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -149,7 +149,8 @@ pub struct AddedImage {
|
||||
pub rotation: u8,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top.
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top (see
|
||||
/// [`AddedImage::crop_valid`]).
|
||||
pub crop: [f64; 4],
|
||||
}
|
||||
|
||||
@@ -158,12 +159,43 @@ impl AddedImage {
|
||||
AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] }
|
||||
}
|
||||
|
||||
/// The least of the image's width and of its height that a crop keeps.
|
||||
pub const MIN_CROP_KEEP: f64 = 0.05;
|
||||
|
||||
/// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is
|
||||
/// `0.9500000000000001` in floating point and still keeps 5 %.
|
||||
const CROP_ROUNDING: f64 = 1e-9;
|
||||
|
||||
/// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least
|
||||
/// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite
|
||||
/// trims nothing.
|
||||
pub fn crop_valid(crop: [f64; 4]) -> bool {
|
||||
let [l, b, r, t] = crop;
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING
|
||||
}
|
||||
|
||||
/// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side
|
||||
/// in range, and an axis that trims too much keeps its two sides' proportion.
|
||||
pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] {
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
|
||||
// Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion;
|
||||
// a pair within the limit keeps each side within it too.
|
||||
let fit = |a: f64, c: f64| {
|
||||
let half = a / 2.0 + c / 2.0;
|
||||
if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) }
|
||||
};
|
||||
let ((l, r), (b, t)) = (fit(l, r), fit(b, t));
|
||||
[l, b, r, t]
|
||||
}
|
||||
|
||||
/// The matrix from image space (the unit square) to display space: crop, flip, rotate,
|
||||
/// then fill the box.
|
||||
fn matrix(&self) -> pdfcraft_content::Matrix {
|
||||
use pdfcraft_content::Matrix as M;
|
||||
let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45));
|
||||
let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05));
|
||||
let [l, b, r, t] = Self::clamp_crop(self.crop);
|
||||
let (cw, ch) = (1.0 - l - r, 1.0 - b - t);
|
||||
let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]);
|
||||
if self.flip_h {
|
||||
m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0]));
|
||||
@@ -1225,6 +1257,21 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leave only item `index` on `page`: its contents become the item's own stream and its
|
||||
/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels
|
||||
/// alone. For previews on a copy of the document; the page's resources stay as they are.
|
||||
pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||||
let a = find(doc, page, index)?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
// An item's stream starts from the page's initial graphics state: `place_tagged` closes
|
||||
// everything drawn before it, so it draws the same on its own.
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)]));
|
||||
d.remove(b"Annots");
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
|
||||
/// names them so), so they go when the item is replaced or deleted.
|
||||
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
|
||||
|
||||
@@ -539,6 +539,11 @@ pub(crate) fn selectable_images(
|
||||
/// Find the saved graphics-state scope containing just this image and its crop. Applying
|
||||
/// the transform before that scope moves the actual clipping path as well as the pixels.
|
||||
/// A shared clip must never be moved along with unrelated artwork or text.
|
||||
///
|
||||
/// Under a clip set outside every scope that holds only this image (a report or table drawing
|
||||
/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active
|
||||
/// clip. The outermost scope holding only the image is used then: it contains all of the
|
||||
/// image's private clips, which move with it, while the outer clip is shared and stays.
|
||||
fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
@@ -556,6 +561,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
let mut text = false;
|
||||
let mut at_image = None;
|
||||
let mut isolated = None;
|
||||
let mut outermost = None;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
@@ -566,9 +572,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some(scope) = stack.pop() {
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_path {
|
||||
if !scope.inherited_clip {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
}
|
||||
// Scopes close inner first, so the last one recorded is the outermost.
|
||||
outermost = Some((scope.start, index, scope.matrix));
|
||||
}
|
||||
matrix = scope.matrix;
|
||||
clipped = scope.inherited_clip;
|
||||
@@ -611,7 +621,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
if !clipped {
|
||||
return Ok(Some((target, target, placement)));
|
||||
}
|
||||
Ok(isolated)
|
||||
Ok(isolated.or(outermost))
|
||||
}
|
||||
|
||||
fn transform_image(
|
||||
|
||||
@@ -57,7 +57,9 @@ pub struct HeaderFooter {
|
||||
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
|
||||
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
|
||||
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
|
||||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, the dotted day-first
|
||||
/// dates of German-speaking and much of Central and Eastern Europe `<<dd.mm.yyyy>>`,
|
||||
/// `<<d.m.yyyy>>`, `<<dd.mm.yy>>`, `<<d.m.yy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||||
/// (digits, start, prefix, suffix).
|
||||
pub text: [String; 6],
|
||||
/// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier.
|
||||
@@ -297,6 +299,10 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C
|
||||
"d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)),
|
||||
"d/m/yyyy" => format!("{d}/{m}/{y}"),
|
||||
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
|
||||
"d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)),
|
||||
"d.m.yyyy" => format!("{d}.{m}.{y}"),
|
||||
"dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)),
|
||||
"dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"),
|
||||
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
|
||||
"yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"),
|
||||
"yyyy/m/d" => format!("{y}/{m}/{d}"),
|
||||
@@ -1038,8 +1044,8 @@ pub use text::{
|
||||
};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,
|
||||
update_content,
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added,
|
||||
scan_markers, update_content,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() {
|
||||
assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_image_crop_keeps_some_of_each_axis() {
|
||||
use crate::added::AddedImage;
|
||||
assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95]));
|
||||
assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5]));
|
||||
// 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is.
|
||||
assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]);
|
||||
assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across");
|
||||
assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across");
|
||||
assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0]));
|
||||
assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is");
|
||||
// From a file: too much across keeps the two sides' proportion; nonsense trims nothing.
|
||||
let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0);
|
||||
// Sides beyond the whole image keep their proportion too, and huge ones don't overflow.
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]);
|
||||
assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]);
|
||||
assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_expand() {
|
||||
let c = cx();
|
||||
@@ -82,6 +107,7 @@ fn tokens_expand() {
|
||||
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
|
||||
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
|
||||
assert_eq!(expand("<<yyyy/mm/dd>> <<yyyy/m/d>>", 1, 1, 0, &c), "2026/10/01 2026/10/1");
|
||||
assert_eq!(expand("<<dd.mm.yyyy>> <<d.m.yyyy>> <<dd.mm.yy>> <<d.m.yy>>", 1, 1, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26");
|
||||
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
|
||||
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
|
||||
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
|
||||
@@ -983,6 +1009,28 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// #4464: a page left with one added item draws that item and nothing else.
|
||||
#[test]
|
||||
fn an_isolated_item_is_all_that_is_left_on_its_page() {
|
||||
let mut doc = fixture();
|
||||
let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() };
|
||||
let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() };
|
||||
add_content(&mut doc, 0, &Content::Text(first)).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(second)).unwrap();
|
||||
let before = streams(&doc, 0).join("\n");
|
||||
assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}");
|
||||
assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item");
|
||||
assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap();
|
||||
isolate_content(&mut doc, 0, 1).unwrap();
|
||||
let after = streams(&doc, 0).join("\n");
|
||||
assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it");
|
||||
assert_eq!(list_added(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste (#2636): where a pasted item lands.
|
||||
#[test]
|
||||
fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() {
|
||||
@@ -4978,6 +5026,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the
|
||||
/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture
|
||||
/// used to show only through its old box) while the section's clip and text stay put.
|
||||
#[test]
|
||||
fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() {
|
||||
// The picture's own clip as wide as the picture, and wider (both seen in one report).
|
||||
for clip_width in [61.2, 85.0] {
|
||||
let mut doc = image_page_with(&format!(
|
||||
"q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q"
|
||||
));
|
||||
let before = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect);
|
||||
let caption = text::text_lines(&doc, 0).unwrap();
|
||||
let target = [162.0, 79.0, 284.4, 135.6];
|
||||
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let after = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
|
||||
assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect);
|
||||
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
|
||||
assert_eq!(
|
||||
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
"the section's text stays where it was"
|
||||
);
|
||||
// The section's clip is shared, so it still bounds the picture: past it, nothing shows.
|
||||
let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap();
|
||||
let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap();
|
||||
assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_image_crop_with_intervening_state_resizes_across_streams() {
|
||||
let variants: Vec<Vec<&[u8]>> = vec![
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -63,7 +63,7 @@ const CKB_DAYS: [&str; 7] = ["یەکشەممە", "دووشەممە", "سێشەم
|
||||
|
||||
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
|
||||
/// the interface languages, in the same order and with the same names.
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 34] = [
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 35] = [
|
||||
DateLanguage {
|
||||
code: "en",
|
||||
name: "English",
|
||||
@@ -267,6 +267,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 34] = [
|
||||
days: CKB_DAYS,
|
||||
days_short: CKB_DAYS,
|
||||
},
|
||||
// Persian (Farsi); Gregorian month and weekday names, right to left like Arabic and Kurdish.
|
||||
// Persian does not change the month form after a day number, so `months_with_day` is `None`.
|
||||
DateLanguage {
|
||||
code: "fa",
|
||||
name: "فارسی",
|
||||
months: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
|
||||
months_with_day: None,
|
||||
months_short: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
|
||||
days: ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"],
|
||||
days_short: ["یک", "دو", "سه", "چه", "پن", "جم", "شن"],
|
||||
},
|
||||
DateLanguage {
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
|
||||
+104
-42
@@ -2461,6 +2461,8 @@ struct EditCtx {
|
||||
xfa_datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A form script ran too long and was abandoned: the document's scripts go off.
|
||||
xfa_ran_away: bool,
|
||||
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
|
||||
xfa_time_scale: u32,
|
||||
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
|
||||
xfa_removed: Vec<String>,
|
||||
/// Pages in the document, for `xfa.layout.pageCount()`.
|
||||
@@ -2495,6 +2497,7 @@ impl EditCtx {
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
xfa_ran_away: false,
|
||||
xfa_time_scale: 1,
|
||||
xfa_removed: Vec::new(),
|
||||
pages: 0,
|
||||
date: now.map(pdfcraft_cos::pdf_date),
|
||||
@@ -2677,7 +2680,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
},
|
||||
Edit::XfaEvent { som, activity } => {
|
||||
let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?;
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run =
|
||||
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
|
||||
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
@@ -3005,7 +3009,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_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,
|
||||
@@ -3015,6 +3019,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,
|
||||
@@ -3026,7 +3032,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).
|
||||
@@ -3066,16 +3074,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))
|
||||
}
|
||||
|
||||
@@ -3225,6 +3241,9 @@ pub struct Session {
|
||||
date_format: Option<String>,
|
||||
/// Preferences ▸ Date format ▸ Language, when not following the interface language.
|
||||
date_language: Option<String>,
|
||||
/// What the XFA scripts' time limits are multiplied by (`None`: 1); see
|
||||
/// [`Session::with_xfa_time_scale`].
|
||||
xfa_time_scale: Option<u32>,
|
||||
}
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
@@ -3233,6 +3252,7 @@ pub struct OpenContext {
|
||||
now: Option<i64>,
|
||||
js_off: bool,
|
||||
trust: Arc<TrustStore>,
|
||||
xfa_time_scale: u32,
|
||||
}
|
||||
|
||||
/// A fully prepared document that has not yet been registered in a session.
|
||||
@@ -3469,7 +3489,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// fields take the data's values.
|
||||
fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> {
|
||||
if let Some(tpl) = cx.xfa.clone() {
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run =
|
||||
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
|
||||
let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run))
|
||||
.unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})")));
|
||||
cx.xfa_datasets = run.datasets;
|
||||
@@ -3512,9 +3533,14 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal
|
||||
fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
let mut changed = Vec::new();
|
||||
// The first field of each name, as a lookup by name would find it.
|
||||
let mut by_name = std::collections::HashMap::new();
|
||||
for f in &fields {
|
||||
by_name.entry(f.name.as_str()).or_insert(f);
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
|
||||
let Some(f) = fields.iter().find(|f| f.name == name) else { continue };
|
||||
let Some(f) = by_name.get(name.as_str()) else { continue };
|
||||
let new = match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
|
||||
// Saved values, one per line for a multi-select list; shown text stands for
|
||||
@@ -3532,13 +3558,14 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<Stri
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
|
||||
pdfcraft_xfa::FieldData::None => None,
|
||||
};
|
||||
if let Some(v) = new
|
||||
&& pdfcraft_forms::set_value(doc, &name, &v).is_ok()
|
||||
{
|
||||
changed.push(name);
|
||||
if let Some(v) = new {
|
||||
updates.push((name, v));
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
// All at once: one by one, each value would read the whole form again, which took minutes
|
||||
// on a form with thousands of fields.
|
||||
let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) };
|
||||
Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect())
|
||||
}
|
||||
|
||||
/// Validate the signature fields of `cos` (written as `bytes`).
|
||||
@@ -3557,6 +3584,17 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
/// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the
|
||||
/// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script
|
||||
/// engine has to start, and each script's own 1 s or 3 s. A test that checks what the
|
||||
/// scripts do passes a large factor, so that a busy machine does not stop them. On native
|
||||
/// builds, 0 stops every script before it starts; the wasm build has no wall clock, so
|
||||
/// there the factor has no effect.
|
||||
pub fn with_xfa_time_scale(mut self, factor: u32) -> Self {
|
||||
self.xfa_time_scale = Some(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// A real clock for a host that has neither a clock nor a time zone of its own (a
|
||||
/// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so
|
||||
/// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in
|
||||
@@ -3629,7 +3667,7 @@ impl Session {
|
||||
|
||||
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
|
||||
pub fn open_context(&self) -> OpenContext {
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() }
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Prepare a document without adding it to this session.
|
||||
@@ -3758,7 +3796,13 @@ impl Session {
|
||||
let mut work = cos.clone();
|
||||
let pages = info.pages.len();
|
||||
let ran = guard(|| {
|
||||
let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false };
|
||||
let mut run = xfa::XfaRun {
|
||||
page_count: pages,
|
||||
out: &mut script_output,
|
||||
datasets: None,
|
||||
ran_away: false,
|
||||
time_scale: context.xfa_time_scale,
|
||||
};
|
||||
let changes = xfa::on_open(&mut work, &tpl, &mut run);
|
||||
if run.ran_away {
|
||||
ran_away = true;
|
||||
@@ -3942,6 +3986,7 @@ impl Session {
|
||||
let now = self.now();
|
||||
let today = self.today();
|
||||
let js_off = self.js_off;
|
||||
let xfa_time_scale = self.xfa_time_scale.unwrap_or(1);
|
||||
let doc = self.doc_mut(id)?;
|
||||
doc.edit_notices.clear();
|
||||
let name = doc.name.clone();
|
||||
@@ -3951,6 +3996,7 @@ impl Session {
|
||||
if !js_off {
|
||||
cx.xfa = doc.xfa_template.clone();
|
||||
}
|
||||
cx.xfa_time_scale = xfa_time_scale;
|
||||
cx.pages = doc.info.pages.len();
|
||||
cx.today = today;
|
||||
let reason = doc.read_only_reason.clone().unwrap_or_default();
|
||||
@@ -4048,7 +4094,13 @@ impl Session {
|
||||
let mut reset = false;
|
||||
collect(&edit, &mut changed, &mut reset);
|
||||
if !changed.is_empty() || reset {
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run = xfa::XfaRun {
|
||||
page_count: cx.pages,
|
||||
out: &mut cx.xfa_out,
|
||||
datasets: cx.xfa_datasets,
|
||||
ran_away: false,
|
||||
time_scale: cx.xfa_time_scale,
|
||||
};
|
||||
// Every changed field's scripts, then the calculations once (after a reset,
|
||||
// only those).
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
|
||||
@@ -4461,7 +4513,8 @@ impl Session {
|
||||
if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) {
|
||||
return Ok(editor.saved.clone());
|
||||
}
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// What the user removed again before saving (a cleared redaction mark) stays out of the file.
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() };
|
||||
// A restored snapshot can be based on an earlier revision. Append to the last saved
|
||||
// file so that revision stays in the chain. A full rewrite (redaction, new encryption,
|
||||
// a reconstructed file) still writes the current graph on its own.
|
||||
@@ -4511,11 +4564,15 @@ impl Session {
|
||||
// An encrypted file needs the password it is protected with now (the owner
|
||||
// password when known, so saving never downgrades this session's rights).
|
||||
let keys = editor.keys.clone();
|
||||
// The saved file carries no Password-field values (#3943); the open document keeps them.
|
||||
// The saved file carries no Password-field values (#3943, #3944); the open document keeps them.
|
||||
let typed = pdfcraft_forms::password_values(&editor.cos);
|
||||
editor.cos =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
if !typed.is_empty() && doc.info.xfa.is_some() {
|
||||
let mut datasets = None;
|
||||
let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets);
|
||||
}
|
||||
editor.saved = bytes;
|
||||
editor.saved_keys = keys;
|
||||
editor.unsaved_file = false;
|
||||
@@ -5120,17 +5177,18 @@ impl Session {
|
||||
let mut out = Vec::new();
|
||||
for d in &mut self.docs {
|
||||
if d.dirty && d.generation != d.snapshot_generation {
|
||||
// The working bytes hold typed Password-field values (#3926): a recovery file is
|
||||
// written from a copy without them, and none is written if that copy fails.
|
||||
let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
// The working bytes hold typed Password-field values (#3926, #3944): a recovery file
|
||||
// is written from a copy without them, and none is written if that copy fails.
|
||||
let bytes =
|
||||
match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
d.snapshot_generation = d.generation;
|
||||
out.push(RecoverySnapshot {
|
||||
doc: d.id,
|
||||
@@ -5502,24 +5560,30 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
/// pop-ups; the number is the comment's position on its page.
|
||||
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
|
||||
use std::fmt::Write;
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect();
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
|
||||
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
|
||||
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
|
||||
let mut children = Children::new();
|
||||
for r in all.iter().filter(|r| r.state.is_none()) {
|
||||
if let Some(parent) = r.parent {
|
||||
children.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
|
||||
// direct parent, so following one level would leave the deeper replies out of the summary.
|
||||
fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) {
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) {
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 1);
|
||||
let _ =
|
||||
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
|
||||
for line in r.contents.as_deref().unwrap_or("").lines() {
|
||||
let _ = writeln!(out, "{indent}{line}");
|
||||
}
|
||||
if let Some(nm) = r.name.as_deref() {
|
||||
write_replies(out, all, nm, depth + 1);
|
||||
}
|
||||
write_replies(out, children, r, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
|
||||
@@ -5558,9 +5622,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
|
||||
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
|
||||
let _ = writeln!(out, "{c}");
|
||||
}
|
||||
if let Some(nm) = a.name.as_deref() {
|
||||
write_replies(&mut out, all, nm, 0);
|
||||
}
|
||||
write_replies(&mut out, &children, a, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
|
||||
@@ -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)? };
|
||||
|
||||
+336
-8
@@ -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);
|
||||
@@ -3496,8 +3650,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() {
|
||||
assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped");
|
||||
img.flip_h = false;
|
||||
img.crop = [0.0, 0.0, 0.5, 0.0];
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
|
||||
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
|
||||
// More than half from one side: only the blue right part is left.
|
||||
img.crop = [0.7, 0.0, 0.0, 0.0];
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
|
||||
assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3720,6 +3878,78 @@ fn comments_lock_take_checkmarks_hide_and_summarize() {
|
||||
assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n");
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the
|
||||
/// annotation itself, or to a link (never listed as a comment), leaves it a comment of its own. Both readers (inspect on open, and the
|
||||
/// comment list the engine refreshes after an edit) must agree.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_names_repeat_across_pages() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R 9 0 R 10 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj
|
||||
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (fitz-A2) /IRT 9 0 R /T (Echo) /Contents (LINKED) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fitz.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let check = |s: &Session, replies: usize| {
|
||||
let all = &s.get(id).unwrap().info.annotations;
|
||||
let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap();
|
||||
assert_eq!(answer.parent, Some((1, 0)), "{answer:?}");
|
||||
let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap();
|
||||
assert!(!looped.is_reply(), "{looped:?}");
|
||||
let linked = all.iter().find(|a| a.contents.as_deref() == Some("LINKED")).unwrap();
|
||||
assert!(!linked.is_reply(), "{linked:?}");
|
||||
let text = comment_summary("fitz.pdf", all, SummarySort::Page);
|
||||
let (cover, second) = text.split_once("SECOND").unwrap();
|
||||
assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}");
|
||||
assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}");
|
||||
assert!(cover.contains("Number: 3 Author: Echo"), "so is the reply to a link: {text}");
|
||||
assert_eq!(second.matches(" Author:").count(), replies, "{text}");
|
||||
};
|
||||
check(&s, 1);
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "LATER".into(), author: "Delta".into() }).unwrap();
|
||||
check(&s, 2);
|
||||
}
|
||||
|
||||
/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its
|
||||
/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open.
|
||||
#[test]
|
||||
fn replies_thread_through_a_reference_chain_after_an_edit() {
|
||||
let pdf = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
5 0 obj 6 0 R endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens");
|
||||
let check = |s: &Session| {
|
||||
let all = &s.get(id).unwrap().info.annotations;
|
||||
let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed");
|
||||
assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}");
|
||||
for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] {
|
||||
let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed");
|
||||
assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}");
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap();
|
||||
check(&s);
|
||||
}
|
||||
|
||||
/// #820: the generated summary includes replies to replies, indented one level deeper.
|
||||
#[test]
|
||||
fn comment_summary_includes_replies_to_replies() {
|
||||
@@ -5354,6 +5584,74 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
|
||||
assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten");
|
||||
}
|
||||
|
||||
/// A static XFA form whose values are only in its datasets takes every one on opening, as
|
||||
/// filling the fields one at a time would leave it: values, appearances and the note; a
|
||||
/// read-only field keeps its own value.
|
||||
#[test]
|
||||
fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
|
||||
let n = 60;
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("many.pdf", None, original.clone(), None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
|
||||
assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision");
|
||||
// The read-only first field keeps its own (empty) value, and is not named.
|
||||
let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning");
|
||||
assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}");
|
||||
assert!(!note.contains("f0[0]"), "{note}");
|
||||
// The same values filled one at a time.
|
||||
let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap();
|
||||
for i in 0..n {
|
||||
let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}")));
|
||||
assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}");
|
||||
}
|
||||
let opened = &doc.editor.as_ref().unwrap().cos;
|
||||
let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec<String>, String)> {
|
||||
pdfcraft_forms::fields(d)
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let w = f.widgets.first().map(|w| w.obj).unwrap();
|
||||
let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a));
|
||||
let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n));
|
||||
let look = match n.as_deref() {
|
||||
Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(f.name, f.value, look)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let got = shown(opened);
|
||||
assert_eq!(got.len(), n);
|
||||
assert_eq!(got, shown(&one_by_one));
|
||||
assert!(got[0].1.is_empty());
|
||||
assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})"))));
|
||||
}
|
||||
|
||||
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
|
||||
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
|
||||
/// times the fields now takes at most about eight times as long (it took about fifty).
|
||||
#[test]
|
||||
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
|
||||
let time = |n: usize| {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let small = time(100);
|
||||
let large = time(800);
|
||||
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
|
||||
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
|
||||
}
|
||||
|
||||
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
|
||||
/// viewers write and read it, and comes back from there when the form is opened again. It used
|
||||
/// to be written as an ISO date whatever the picture said.
|
||||
@@ -5437,6 +5735,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
|
||||
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
|
||||
}
|
||||
|
||||
/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take
|
||||
/// values that scripts read, and are saved into the datasets.
|
||||
#[test]
|
||||
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
let data = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
|
||||
@@ -5456,7 +5756,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
|
||||
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
|
||||
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
|
||||
assert_eq!(field(&s, "sign").kind, K::Signature);
|
||||
// The signature field is named by its SOM path, as another viewer finds a signature.
|
||||
assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature);
|
||||
// Scripts read a choice list's saved value.
|
||||
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Choosing another item recalculates; the list takes several values; a typed value is kept
|
||||
@@ -5614,7 +5915,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_
|
||||
#[test]
|
||||
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
// Raised time limits: a busy machine does not stop the calculations this checks.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
|
||||
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
|
||||
@@ -5643,16 +5945,19 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
|
||||
}
|
||||
|
||||
/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within
|
||||
/// the opening's budget, and a change recalculates them within the event's.
|
||||
/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine, and a
|
||||
/// change recalculates them in one more. The time limits are raised so that a busy machine
|
||||
/// does not stop them; the next test checks that the limits stop them.
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
fn hundreds_of_xfa_calculations_run_in_one_engine() {
|
||||
let (sections, rows) = (10, 20);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let engines = pdfcraft_js::xfa_session::engines_started();
|
||||
let started = std::time::Instant::now();
|
||||
let id = s.open("many.pdf", None, bytes, None).expect("opens");
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 1, "the scripts of the opening share one engine");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
|
||||
let form = &s.get(id).unwrap().form;
|
||||
@@ -5665,9 +5970,10 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
let expect = (rows * (rows + 1)).to_string();
|
||||
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
|
||||
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
|
||||
// A change recalculates them all again, well within the event budget.
|
||||
// A change recalculates them all again, in one more engine.
|
||||
let started = std::time::Instant::now();
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 2, "the scripts of the change share one engine");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
|
||||
@@ -5675,6 +5981,28 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
eprintln!("recalculated after a change in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// The time the scripts of an opening share stops the calculations it has no time for, and
|
||||
/// says how many were skipped; a change's scripts are stopped the same way.
|
||||
#[test]
|
||||
fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() {
|
||||
let (sections, rows) = (10, 20);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
|
||||
// A time scale of 0 leaves no time at all: every script is skipped before it starts.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(0);
|
||||
let engines = pdfcraft_js::xfa_session::engines_started();
|
||||
let id = s.open("many.pdf", None, bytes, None).expect("opens");
|
||||
let skipped = sections * rows + sections;
|
||||
let stopped = format!("The form's scripts were stopped: the scripts took more than 0 seconds; {skipped} more were skipped");
|
||||
assert_eq!(s.take_js_output(id).errors, vec![stopped.clone()]);
|
||||
let totals = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("total")).map(|f| f.value.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(totals(&s).len(), sections * rows);
|
||||
assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s));
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
|
||||
assert!(s.take_js_output(id).errors.iter().any(|e| e.starts_with("The form's scripts were stopped: the scripts took more than 0 seconds")));
|
||||
assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s));
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started");
|
||||
}
|
||||
|
||||
/// The scripts of one event share the form as the engine holds it: a failed script's
|
||||
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
|
||||
/// by its shown text.
|
||||
|
||||
@@ -89,4 +89,17 @@ impl Document {
|
||||
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
|
||||
})?
|
||||
}
|
||||
|
||||
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
|
||||
/// so the page under its first place doesn't come along. Read-only, like
|
||||
/// [`Self::added_content_preview`].
|
||||
pub fn added_content_alone(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let mut cos = editor.cos.clone();
|
||||
pdfcraft_edit::isolate_content(&mut cos, page, index).map_err(|e| e.to_string())?;
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(TextEditPreview { page, bytes, config: RenderConfig { transparent: true, ..self.config.clone() } })
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ pub(crate) struct XfaRun<'a> {
|
||||
pub datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A script ran past its time limit and was abandoned: the form's scripts should stay off.
|
||||
pub ran_away: bool,
|
||||
/// What every time limit of the scripts is multiplied by (the event's budget, the engine's
|
||||
/// start and each script's own): 1, unless the session was given another
|
||||
/// ([`crate::Session::with_xfa_time_scale`]).
|
||||
pub time_scale: u32,
|
||||
}
|
||||
|
||||
/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
|
||||
@@ -277,6 +281,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self {
|
||||
let data = pdfcraft_xfa::data_of(doc);
|
||||
let ov = pdfcraft_xfa::overrides(doc);
|
||||
let budget_ms = budget_ms.saturating_mul(u64::from(run.time_scale));
|
||||
Runner {
|
||||
doc,
|
||||
tpl,
|
||||
@@ -311,7 +316,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
/// The script engine for this event.
|
||||
fn session(&mut self) -> &mut XfaSession {
|
||||
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
|
||||
self.session.get_or_insert_with(|| XfaSession::new(doc_info))
|
||||
let scale = self.run.time_scale;
|
||||
self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale))
|
||||
}
|
||||
|
||||
/// Give the object at `som` the value `text` in the data and in the live form (refreshed
|
||||
@@ -661,7 +667,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
event,
|
||||
root,
|
||||
limits: limits_for(&ev.activity),
|
||||
timeout: timeout_for(&ev.activity),
|
||||
timeout: timeout_for(&ev.activity).saturating_mul(self.run.time_scale),
|
||||
};
|
||||
let o = self.session().run(script);
|
||||
if o.abandoned {
|
||||
@@ -893,7 +899,7 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
|
||||
r.run(&ev, "")?;
|
||||
}
|
||||
// The calculations that follow get a budget of their own.
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS);
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale)));
|
||||
r.calculates()?;
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?;
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(true))?;
|
||||
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?;
|
||||
set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?;
|
||||
// Many values: as set_value one by one, but reading the form once and recalculating once.
|
||||
let results: Vec<Result<(), FormError>> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?;
|
||||
reset(&mut doc, None)?; // Clear form (back to /DV)
|
||||
|
||||
// Prepare a form
|
||||
|
||||
+12
-1
@@ -844,7 +844,10 @@ fn group(int: &str, sep: char) -> String {
|
||||
fn number_text(v: f64, decimals: u8, sep: u8) -> String {
|
||||
// Round half away from zero, as JavaScript's toFixed does for these values.
|
||||
let p = 10f64.powi(i32::from(decimals));
|
||||
let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p);
|
||||
let scaled = v.abs() * p;
|
||||
// Past 2^53 every value is already whole, and scaling could overflow to infinity.
|
||||
let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() };
|
||||
let s = format!("{:.*}", decimals as usize, r);
|
||||
let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b));
|
||||
let (g, d) = match sep {
|
||||
1 => (None, '.'),
|
||||
@@ -2060,4 +2063,12 @@ mod undelimited_tests {
|
||||
assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None);
|
||||
assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_finite_numbers_do_not_print_inf() {
|
||||
let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307");
|
||||
assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}");
|
||||
let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306");
|
||||
assert!(!p.contains("inf"), "got {p}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
|
||||
/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`, written by the XFA layout).
|
||||
fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||||
}
|
||||
|
||||
|
||||
@@ -963,6 +963,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
list: None,
|
||||
round: true,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g");
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
|
||||
+114
-9
@@ -118,6 +118,9 @@ pub struct Widget {
|
||||
pub caption: Option<String>,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`).
|
||||
pub round: bool,
|
||||
}
|
||||
|
||||
/// A terminal form field.
|
||||
@@ -570,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields {
|
||||
Fields { items, adopted_page_fields }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Whole-form readings ([`enumerate`]) on this thread.
|
||||
static FORM_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times this thread has read the whole form.
|
||||
#[cfg(test)]
|
||||
fn form_reads() -> usize {
|
||||
FORM_READS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
||||
#[cfg(test)]
|
||||
FORM_READS.with(|n| n.set(n.get() + 1));
|
||||
let af = acroform(doc);
|
||||
let mut page_of = std::collections::HashMap::new();
|
||||
let mut annot_index = std::collections::HashMap::new();
|
||||
@@ -882,6 +899,7 @@ fn walk(
|
||||
})
|
||||
.flatten(),
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -931,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script
|
||||
if all.is_empty() {
|
||||
return Err(FormError::NoForm);
|
||||
}
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let named: Vec<usize> = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect();
|
||||
fill(doc, &all, &named, name, value, scripts)?;
|
||||
recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and
|
||||
/// recalculating once, after the last value. Filling fields one by one reads the whole form twice
|
||||
/// per field, which takes minutes on a form with thousands of fields.
|
||||
///
|
||||
/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after
|
||||
/// which, as with [`set_value`], no value counts as filled (though each was written).
|
||||
pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result<Vec<Result<(), FormError>>, FormError> {
|
||||
let mut scripts = NoScripts;
|
||||
let mut form = NamedFields::read(doc);
|
||||
// The field objects this batch wrote since the form was last read.
|
||||
let mut written = std::collections::HashSet::new();
|
||||
let mut results = Vec::with_capacity(values.len());
|
||||
let mut filled = false;
|
||||
for (name, value) in values {
|
||||
if form.all.is_empty() {
|
||||
results.push(Err(FormError::NoForm));
|
||||
continue;
|
||||
}
|
||||
// A field written earlier in the batch (named twice, or a twin's name) is read again,
|
||||
// so this write starts from what the earlier one left, as it would one by one.
|
||||
if form.objs(name).any(|o| written.contains(&o)) {
|
||||
form = NamedFields::read(doc);
|
||||
written.clear();
|
||||
}
|
||||
let named = form.named(name);
|
||||
let result = fill(doc, &form.all, named, name, value, &mut scripts);
|
||||
written.extend(form.objs(name));
|
||||
filled |= result.is_ok();
|
||||
results.push(result);
|
||||
}
|
||||
if filled {
|
||||
recalculate_with(doc, &mut scripts)?;
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The form's fields, with the positions of each fully qualified name in them (in field order).
|
||||
struct NamedFields {
|
||||
all: Vec<Field>,
|
||||
by_name: std::collections::HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl NamedFields {
|
||||
fn read(doc: &Document) -> Self {
|
||||
let all = fields(doc);
|
||||
let mut by_name: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, f) in all.iter().enumerate() {
|
||||
by_name.entry(f.name.clone()).or_default().push(i);
|
||||
}
|
||||
NamedFields { all, by_name }
|
||||
}
|
||||
|
||||
fn named(&self, name: &str) -> &[usize] {
|
||||
self.by_name.get(name).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The field objects named `name`.
|
||||
fn objs<'a>(&'a self, name: &str) -> impl Iterator<Item = ObjRef> + 'a {
|
||||
self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `value` into the field `name`, without recalculating: `named` are the positions in
|
||||
/// `all` (the form as read before this write) of the fields with that name, in field order.
|
||||
fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
if f.read_only() {
|
||||
return Err(FormError::ReadOnly(name.into()));
|
||||
}
|
||||
write_value(doc, f, value, scripts)?;
|
||||
author::form_fonts_as_objects(doc, &all)?;
|
||||
author::form_fonts_as_objects(doc, all)?;
|
||||
// Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a
|
||||
// name as separate field dictionaries, instead of widgets under one parent, get the value
|
||||
// the first one took after its scripts, without running them again.
|
||||
let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect();
|
||||
let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect();
|
||||
if !twins.is_empty() {
|
||||
let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default();
|
||||
for t in twins {
|
||||
share_value(doc, f.obj, t, &taken, scripts)?;
|
||||
}
|
||||
}
|
||||
recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1253,6 +1341,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
[one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
|
||||
_ => None,
|
||||
};
|
||||
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
let mut undrawn = false;
|
||||
for w in &f.widgets {
|
||||
let (stream, missing) = match &shown {
|
||||
@@ -1260,7 +1349,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
None => appearance::field_appearance_drawn(doc, f, w, values, true),
|
||||
};
|
||||
undrawn |= missing.is_some();
|
||||
let ap = doc.add(Object::Stream(stream));
|
||||
let existing_ap = in_place
|
||||
.then(|| {
|
||||
doc.get(w.obj)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"AP").map(|a| doc.resolve(a)))
|
||||
.and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| n.as_ref())
|
||||
.filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_)))
|
||||
})
|
||||
.flatten();
|
||||
let ap = match existing_ap {
|
||||
Some(r) => {
|
||||
doc.set(r, Object::Stream(stream));
|
||||
r
|
||||
}
|
||||
None => doc.add(Object::Stream(stream)),
|
||||
};
|
||||
let mut fresh = Dict::new();
|
||||
fresh.set(b"N".to_vec(), Object::Ref(ap));
|
||||
let apd = merged_appearance(doc, w.obj, &fresh);
|
||||
@@ -1315,7 +1420,7 @@ fn password_fields(doc: &Document) -> Vec<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 +1428,7 @@ fn has_password_flag(doc: &Document) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some((o, depth)) = stack.pop() {
|
||||
let Object::Ref(r) = o else { continue };
|
||||
if depth > 32 || !seen.insert(r) {
|
||||
if depth > 64 || !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if sets_it(r) {
|
||||
@@ -1363,11 +1468,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats.
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats.
|
||||
fn field_chain(doc: &Document, r: ObjRef) -> Vec<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]
|
||||
@@ -4300,3 +4359,145 @@ fn comb_fields_show_their_cells() {
|
||||
let plain = saved_look(&doc, "d");
|
||||
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
|
||||
}
|
||||
|
||||
/// A form for filling many values at once: two summed fields and their calculated total, a
|
||||
/// name two field dictionaries share, a check box, a radio group, a list and a read-only field.
|
||||
fn batch_form() -> Document {
|
||||
let tx = |name: &str, y: u32, rest: &str| {
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
|
||||
};
|
||||
let objs: Vec<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 /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>"
|
||||
.into(),
|
||||
tx("a", 700, "/V (1)"),
|
||||
tx("b", 660, ""),
|
||||
tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#),
|
||||
tx("dup", 580, ""),
|
||||
tx("dup", 540, ""),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(),
|
||||
"<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>"
|
||||
.into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
tx("locked", 500, "/Ff 1 /V (kept)"),
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
/// What a filled form shows: every field as read, and each widget's drawn appearance.
|
||||
fn filled(doc: &Document) -> (Vec<Field>, Vec<String>) {
|
||||
let all = fields(doc);
|
||||
let looks = all
|
||||
.iter()
|
||||
.flat_map(|f| &f.widgets)
|
||||
.map(|w| {
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()));
|
||||
match n.map(|n| doc.resolve(&n)) {
|
||||
Some(n) => match &*n {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(),
|
||||
other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(all, looks)
|
||||
}
|
||||
|
||||
/// Filling values in one batch leaves the form as filling them one by one does: the values,
|
||||
/// the twin of a shared name, the calculated total and every widget's appearance; a name given
|
||||
/// twice ends with its last value, and is checked against the value it took earlier in the batch;
|
||||
/// and each value fails as it would on its own.
|
||||
#[test]
|
||||
fn set_values_fills_as_set_value_does_one_by_one() {
|
||||
let values: Vec<(String, FieldValue)> = vec![
|
||||
("a".into(), FieldValue::Text("2".into())),
|
||||
("b".into(), FieldValue::Text("3".into())),
|
||||
("dup".into(), FieldValue::Text("shared".into())),
|
||||
("agree".into(), FieldValue::Check(true)),
|
||||
("size".into(), FieldValue::Radio(Some("L".into()))),
|
||||
("country".into(), FieldValue::Choice(vec!["fr".into()])),
|
||||
("nowhere".into(), FieldValue::Text("x".into())),
|
||||
("locked".into(), FieldValue::Text("changed".into())),
|
||||
("size".into(), FieldValue::Radio(Some("XL".into()))),
|
||||
("dup".into(), FieldValue::Text("again".into())),
|
||||
("a".into(), FieldValue::Text("10".into())),
|
||||
// Refused only because the batch already selected L: the group keeps one selected.
|
||||
("size".into(), FieldValue::Radio(None)),
|
||||
];
|
||||
let mut one_by_one = batch_form();
|
||||
let expected: Vec<Result<(), FormError>> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect();
|
||||
let mut batch = batch_form();
|
||||
let results = set_values(&mut batch, &values).expect("recalculates");
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(
|
||||
results.iter().filter(|r| r.is_err()).count(),
|
||||
4,
|
||||
"the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}"
|
||||
);
|
||||
let (all, looks) = filled(&batch);
|
||||
assert_eq!((all.clone(), looks), filled(&one_by_one));
|
||||
assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value");
|
||||
assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value");
|
||||
// And as saved.
|
||||
assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one)));
|
||||
}
|
||||
|
||||
/// A form without fields refuses each value, and an empty batch changes nothing.
|
||||
#[test]
|
||||
fn set_values_without_a_form_or_values() {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
]);
|
||||
let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap();
|
||||
assert_eq!(r, vec![Err(FormError::NoForm)]);
|
||||
let mut form = batch_form();
|
||||
assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new());
|
||||
assert!(!form.is_modified());
|
||||
// Nothing filled, nothing recalculated: the total stays as the file has it.
|
||||
let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap();
|
||||
assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]);
|
||||
assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified());
|
||||
}
|
||||
|
||||
/// Filling many fields reads the form a fixed number of times, not once or twice per field
|
||||
/// (which took minutes to open a form with thousands of fields whose XFA data was filled in
|
||||
/// elsewhere).
|
||||
#[test]
|
||||
fn set_values_reads_the_form_once_not_per_value() {
|
||||
let n: u32 = 60;
|
||||
let mut objs: Vec<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()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 5)).collect();
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")));
|
||||
objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" ")));
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10));
|
||||
}
|
||||
let mut doc = document(&objs);
|
||||
let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect();
|
||||
let before = form_reads();
|
||||
let results = set_values(&mut doc, &values).unwrap();
|
||||
let reads = form_reads() - before;
|
||||
assert!(results.iter().all(Result::is_ok));
|
||||
// One reading to fill, one to recalculate.
|
||||
assert!(reads <= 2, "{reads} readings of the form for {n} values");
|
||||
assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])]));
|
||||
// A name filled again is read again, once: f0 and f1 again cost one more reading.
|
||||
let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect();
|
||||
let before = form_reads();
|
||||
assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok));
|
||||
let reads = form_reads() - before;
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
|
||||
@@ -2115,7 +2115,8 @@ impl Interp<'_> {
|
||||
let a = self.arg_num(args, 0)?;
|
||||
let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 };
|
||||
let k = 10f64.powi(places);
|
||||
Val::Num((a * k).round() / k)
|
||||
let scaled = a * k;
|
||||
if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) }
|
||||
}
|
||||
"count" => {
|
||||
// Non-null values and objects: an empty field doesn't count, a subform does.
|
||||
@@ -2278,8 +2279,11 @@ impl Interp<'_> {
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& let Some(h) =
|
||||
b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
&& let Some(h) = b
|
||||
.get(i + 1..i + 3)
|
||||
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
|
||||
.and_then(|h| std::str::from_utf8(h).ok())
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(h);
|
||||
i += 3;
|
||||
|
||||
@@ -1009,9 +1009,17 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_keeps_huge_values_finite() {
|
||||
assert_eq!(calc("Round(1e300, 12) == 1e300"), "1");
|
||||
assert_eq!(calc("Round(1.2345, 2)"), "1.23");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_functions_cover_the_common_set() {
|
||||
assert_eq!(calc("Abs(-2.5)"), "2.5");
|
||||
assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b");
|
||||
assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb");
|
||||
assert_eq!(calc("Ceil(2.1)"), "3");
|
||||
assert_eq!(calc("Floor(-2.1)"), "-3");
|
||||
assert_eq!(calc("Mod(7, 3)"), "1");
|
||||
|
||||
@@ -18,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome};
|
||||
#[cfg(test)]
|
||||
pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
std::thread_local! {
|
||||
static ENGINES_STARTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many script engine threads the sessions made on this thread have started, for
|
||||
/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of
|
||||
/// calculations starts one. Per thread, so a caller counting its own events is not disturbed
|
||||
/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread.
|
||||
pub fn engines_started() -> u64 {
|
||||
ENGINES_STARTED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// What one script run needs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Script {
|
||||
@@ -58,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{Script, run_script};
|
||||
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
|
||||
@@ -83,11 +95,20 @@ mod native {
|
||||
worker: Option<Worker>,
|
||||
/// A script ran past its limit: its thread is left to it and nothing more runs.
|
||||
dead: Option<String>,
|
||||
/// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default).
|
||||
start_limit: Duration,
|
||||
}
|
||||
|
||||
impl XfaSession {
|
||||
pub fn new(doc: XfaDoc) -> XfaSession {
|
||||
XfaSession { doc, worker: None, dead: None }
|
||||
XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT }
|
||||
}
|
||||
|
||||
/// Multiply the time the engine has to be ready for a script by `factor` (a script's
|
||||
/// own time is [`Script::timeout`]).
|
||||
pub fn with_time_scale(mut self, factor: u32) -> XfaSession {
|
||||
self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Why nothing more runs, once a script was abandoned.
|
||||
@@ -115,11 +136,12 @@ mod native {
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("the script engine could not start: {e}"))?;
|
||||
match from.recv_timeout(ENGINE_START_LIMIT) {
|
||||
super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1)));
|
||||
match from.recv_timeout(self.start_limit) {
|
||||
Ok(Reply::Ready) => {}
|
||||
Ok(_) => return Err("the script engine answered out of turn".into()),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()));
|
||||
return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64()));
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()),
|
||||
}
|
||||
@@ -129,8 +151,9 @@ mod native {
|
||||
}
|
||||
|
||||
/// Run one script, waiting at most its `timeout` from when the engine is ready for it
|
||||
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is
|
||||
/// abandoned on its thread and the session is dead.
|
||||
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless
|
||||
/// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread
|
||||
/// and the session is dead.
|
||||
pub fn run(&mut self, script: Script) -> XfaOutcome {
|
||||
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
|
||||
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
|
||||
@@ -138,6 +161,7 @@ mod native {
|
||||
return abandoned(why.clone());
|
||||
}
|
||||
let timeout = script.timeout;
|
||||
let start_limit = self.start_limit;
|
||||
let worker = match self.worker() {
|
||||
Ok(w) => w,
|
||||
Err(why) => return failed(why),
|
||||
@@ -148,7 +172,7 @@ mod native {
|
||||
return failed("the script engine stopped with an internal error".into());
|
||||
}
|
||||
// The engine's start, then the script's own time.
|
||||
let (mut running, mut limit) = (false, ENGINE_START_LIMIT);
|
||||
let (mut running, mut limit) = (false, start_limit);
|
||||
let mut deadline = Instant::now().checked_add(limit);
|
||||
loop {
|
||||
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
|
||||
@@ -167,7 +191,7 @@ mod native {
|
||||
let why = if running {
|
||||
format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())
|
||||
} else {
|
||||
format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())
|
||||
format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())
|
||||
};
|
||||
self.dead = Some(why.clone());
|
||||
// Dropping the sender ends the thread's loop once the script is done.
|
||||
@@ -199,6 +223,11 @@ mod native {
|
||||
XfaSession { doc }
|
||||
}
|
||||
|
||||
/// No engine to start on wasm: nothing to scale.
|
||||
pub fn with_time_scale(self, _factor: u32) -> XfaSession {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dead(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -265,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a
|
||||
/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests`
|
||||
/// tree, then the catalog `/Dests`. `None` when a name names nothing.
|
||||
pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option<Vec<Object>> {
|
||||
// Names only: the page map is not needed to look one up.
|
||||
let mut targets = Targets { pages: HashMap::new(), names: None };
|
||||
explicit_dest(doc, &mut targets, dest, 0)
|
||||
}
|
||||
|
||||
/// The page index an explicit destination array names.
|
||||
fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option<usize> {
|
||||
match dest.first()? {
|
||||
|
||||
@@ -2460,10 +2460,89 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() {
|
||||
assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() });
|
||||
// A changed magnification writes a destination of its own.
|
||||
let mut doc = doc;
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap();
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap();
|
||||
let catalog = doc.get(root).as_dict().cloned().unwrap();
|
||||
assert_ne!(catalog.get(b"OpenAction"), Some(&action));
|
||||
assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage);
|
||||
assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at
|
||||
/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree.
|
||||
fn doc_opening_at(action: Object) -> Document {
|
||||
let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0));
|
||||
let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0));
|
||||
let mut dests = pdfcraft_cos::Dict::new();
|
||||
dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)]));
|
||||
let mut tree = pdfcraft_cos::Dict::new();
|
||||
tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])]));
|
||||
let mut names = pdfcraft_cos::Dict::new();
|
||||
names.set(b"Dests".to_vec(), Object::Dict(tree));
|
||||
let mut doc = open(fixture());
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"OpenAction".to_vec(), action);
|
||||
c.set(b"Dests".to_vec(), Object::Dict(dests));
|
||||
c.set(b"Names".to_vec(), Object::Dict(names));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn open_action(doc: &Document) -> Option<Object> {
|
||||
doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned())
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing
|
||||
/// page 1 in Document Properties on a document that opens at a named destination elsewhere
|
||||
/// writes a destination to page 1; it used to read the name as page 1 and change nothing.
|
||||
#[test]
|
||||
fn choosing_page_one_replaces_a_named_opening_destination() {
|
||||
use crate::view::Magnification;
|
||||
let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0));
|
||||
let mut goto = pdfcraft_cos::Dict::new();
|
||||
goto.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
goto.set(b"D".to_vec(), Object::name("there"));
|
||||
for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] {
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
// The dialog shows where the document really opens.
|
||||
let v = crate::initial_view(&doc);
|
||||
assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}");
|
||||
// Page 1 at the same magnification: an explicit destination to page 1.
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap();
|
||||
let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]);
|
||||
assert_eq!(open_action(&doc), Some(written), "{action:?}");
|
||||
let reopened = save_and_reopen(&doc);
|
||||
assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}");
|
||||
// Page 1 at the default magnification: no opening action, so the first page opens.
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap();
|
||||
assert_eq!(open_action(&doc), None, "{action:?}");
|
||||
assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A named opening destination that already goes to the chosen page and magnification is kept,
|
||||
/// and so is one that names nothing (it reads as page 1) until another page is chosen.
|
||||
#[test]
|
||||
fn a_named_opening_destination_is_kept_while_it_still_holds() {
|
||||
use crate::view::Magnification;
|
||||
let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0));
|
||||
for (action, magnification) in
|
||||
[(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)]
|
||||
{
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
let v = crate::initial_view(&doc);
|
||||
assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}");
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap();
|
||||
assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}");
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap();
|
||||
let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" };
|
||||
assert_eq!(
|
||||
open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())),
|
||||
Some((Some(page2.clone()), Some(Object::name(kind)))),
|
||||
"{action:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,12 @@ pub fn initial_view(doc: &Document) -> InitialView {
|
||||
};
|
||||
}
|
||||
}
|
||||
// The opening destination: an explicit array, or a GoTo action's.
|
||||
// The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a
|
||||
// name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification
|
||||
// read here are where the document opens. A name that names nothing reads as page 1.
|
||||
let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o {
|
||||
Object::Array(a) => Some(a.clone()),
|
||||
Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()),
|
||||
Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o),
|
||||
Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(d) = dest {
|
||||
@@ -195,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ
|
||||
} else {
|
||||
vp.remove(b"Direction");
|
||||
}
|
||||
// A destination, explicit or named (§12.3.2.4), or a GoTo action to one.
|
||||
let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) {
|
||||
None => true,
|
||||
Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")),
|
||||
Some(o) => {
|
||||
matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo"))
|
||||
}
|
||||
};
|
||||
let dest = match v.magnification {
|
||||
Magnification::Default if v.page == 0 => None,
|
||||
|
||||
@@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref
|
||||
|
||||
On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested.
|
||||
|
||||
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
|
||||
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
|
||||
|
||||
@@ -34,7 +34,8 @@ pub fn create_staging(
|
||||
/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on
|
||||
/// Unix. It is created readable and writable by its owner only and then given them, so its mode
|
||||
/// is never more open than that of the file it will replace, not even while it is still empty (a
|
||||
/// handle opened then would keep reading what is written later). The owner is the saver's.
|
||||
/// handle opened then would keep reading what is written later). The owner is the saver's. On
|
||||
/// Windows it is created with that file's [`KEPT_ATTRIBUTES`].
|
||||
fn create_staging_as(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
@@ -63,8 +64,15 @@ fn create_staging_as(
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
// Given as the file is created, so a hidden target's staging file is never visible.
|
||||
#[cfg(windows)]
|
||||
if let Some(keep) = keep {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES);
|
||||
}
|
||||
match options.open(&tmp) {
|
||||
Ok(file) => {
|
||||
#[cfg(unix)]
|
||||
if let Some(keep) = keep {
|
||||
// A volume that can't change modes (FAT, some network shares) leaves the file
|
||||
// owner-only: stricter than the file it replaces, never looser.
|
||||
@@ -78,9 +86,18 @@ fn create_staging_as(
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = keep;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken"))
|
||||
}
|
||||
|
||||
/// The Windows attributes a replaced file passes on to its staging file (#4593):
|
||||
/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the
|
||||
/// file. A read-only file is refused before anything is staged, and the archive bit is the
|
||||
/// system's to set on a changed file; the other attributes describe how the volume stores it.
|
||||
#[cfg(windows)]
|
||||
const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4;
|
||||
|
||||
/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits
|
||||
/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver
|
||||
/// isn't a member), group and others get nothing: someone the group bits shut out would
|
||||
@@ -96,11 +113,6 @@ fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::
|
||||
mode
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions {
|
||||
keep.permissions()
|
||||
}
|
||||
|
||||
/// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the
|
||||
/// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3),
|
||||
/// keyed from the operating system's random source. That is all a staging name needs, but it is
|
||||
@@ -119,7 +131,8 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
|
||||
/// a folder at the target is refused by the rename. Flushing is the staging file's own
|
||||
/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
|
||||
/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the
|
||||
/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
|
||||
/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and
|
||||
/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where
|
||||
/// the volume stores one.
|
||||
/// On macOS, accessible extended attributes of an existing regular destination are copied
|
||||
/// before flushing; a copy failure refuses the save rather than losing metadata (#2658).
|
||||
@@ -146,10 +159,14 @@ pub fn write_atomically_with(
|
||||
}
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save");
|
||||
// A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before.
|
||||
// A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and
|
||||
// system attributes on Windows (#4593). A save replaces a symbolic link, not the file it
|
||||
// points to, so on Windows the attributes are the link's own.
|
||||
#[cfg(unix)]
|
||||
let keep = std::fs::metadata(path).ok();
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
let keep = std::fs::symlink_metadata(path).ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let keep = None;
|
||||
// A saved file is the same document: it keeps its creation date (#2658).
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
@@ -661,4 +678,69 @@ mod tests {
|
||||
assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and
|
||||
/// its staging file already is while it is written; a plain file stays plain, though the
|
||||
/// staging name starts with a dot; a new file gets no attribute from anywhere.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_replaced_file_keeps_its_hidden_and_system_attributes() {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
const HIDDEN: u32 = 0x2;
|
||||
const SYSTEM: u32 = 0x4;
|
||||
let dir = test_dir("attributes");
|
||||
let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM);
|
||||
for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() {
|
||||
let target = dir.join(format!("doc{i}.pdf"));
|
||||
let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap();
|
||||
std::io::Write::write_all(&mut fixture, b"OLD").unwrap();
|
||||
drop(fixture);
|
||||
assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}");
|
||||
for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] {
|
||||
let mut staged = u32::MAX;
|
||||
write_atomically_with(&target, b"NEW", form, [i as u64], |f| {
|
||||
staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed");
|
||||
assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
|
||||
}
|
||||
}
|
||||
let fresh = dir.join("new.pdf");
|
||||
write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap();
|
||||
assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A save replaces a symbolic link, not the file it points to, so the saved file takes the
|
||||
/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file
|
||||
/// it pointed to is left as it was.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_replaced_link_keeps_its_own_attributes_not_its_targets() {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
const HIDDEN: u32 = 0x2;
|
||||
let dir = test_dir("link-attributes");
|
||||
let referent = dir.join("referent.pdf");
|
||||
let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap();
|
||||
std::io::Write::write_all(&mut fixture, b"OLD").unwrap();
|
||||
drop(fixture);
|
||||
let link = dir.join("link.pdf");
|
||||
if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) {
|
||||
eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible");
|
||||
write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap();
|
||||
let saved = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(!saved.file_type().is_symlink(), "the save replaced the link");
|
||||
assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is");
|
||||
assert_eq!(std::fs::read(&link).unwrap(), b"NEW");
|
||||
assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched");
|
||||
assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+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::{
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A luminosity soft mask takes its values from the gray of its group's colour (ISO 32000-2
|
||||
//! §11.5.3). In a `/DeviceCMYK` group that gray is `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)`
|
||||
//! (§10.4.2.3), so 100% K masks fully, whatever profile CMYK is displayed through.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
/// The page's grey backdrop, `0.5 g`, as the renderer writes it.
|
||||
const BACKDROP: f64 = 128.0;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
fn render(bytes: Vec<u8>) -> (Vec<u8>, u32) {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
(page.rgba.to_vec(), page.width)
|
||||
}
|
||||
|
||||
fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 3] {
|
||||
let offset = usize::try_from((y * width + x) * 4).unwrap();
|
||||
[rgba[offset], rgba[offset + 1], rgba[offset + 2]]
|
||||
}
|
||||
|
||||
/// Red filled over the grey backdrop under a luminosity mask whose group, in `space`, paints its
|
||||
/// left half with `paint`; its right half is the backdrop `/BC` (`bc`, when given). Returns the
|
||||
/// mask values read back from the left and the right half.
|
||||
fn mask_values(space: &str, paint: &str, bc: Option<&str>) -> (f64, f64) {
|
||||
let bc = bc.map(|bc| format!("/BC {bc}")).unwrap_or_default();
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >>".to_string(),
|
||||
stream("", "0.5 g 0 0 100 100 re f /M gs 1 0 0 rg 0 0 100 100 re f"),
|
||||
format!("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R {bc} >> >>"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS {space} >>"),
|
||||
&format!("{paint} 0 0 50 100 re f"),
|
||||
),
|
||||
];
|
||||
let (rgba, width) = render(pdf(&objects));
|
||||
let value = |x| {
|
||||
let [r, g, b] = pixel(&rgba, width, x, 50);
|
||||
// Red over grey: red rises from the backdrop to 255 and green and blue fall to 0 as the
|
||||
// mask goes from 0 to 1.
|
||||
let from_red = (f64::from(r) - BACKDROP) / (255.0 - BACKDROP);
|
||||
let from_green = (BACKDROP - f64::from(g)) / BACKDROP;
|
||||
let from_blue = (BACKDROP - f64::from(b)) / BACKDROP;
|
||||
assert!(
|
||||
(from_red - from_green).abs() < 0.03 && (from_green - from_blue).abs() < 0.03,
|
||||
"{space} {paint}: pixel {:?} is not red over the backdrop",
|
||||
[r, g, b]
|
||||
);
|
||||
from_green
|
||||
};
|
||||
(value(25), value(75))
|
||||
}
|
||||
|
||||
fn assert_mask(space: &str, paint: &str, bc: Option<&str>, left: f64, right: f64) {
|
||||
let (got_left, got_right) = mask_values(space, paint, bc);
|
||||
assert!((got_left - left).abs() < 0.02, "{space} group, `{paint}`: mask {got_left:.3}, expected {left}");
|
||||
assert!((got_right - right).abs() < 0.02, "{space} group, /BC {bc:?}: mask {got_right:.3}, expected {right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully() {
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0 k", None, 1.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0.5 k", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_process_ink_in_a_device_cmyk_group_masks_by_its_weight() {
|
||||
assert_mask("/DeviceCMYK", "1 0 0 0 k", None, 0.7, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 1 0 0 k", None, 0.41, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 1 0 k", None, 0.89, 0.0);
|
||||
assert_mask("/DeviceCMYK", "1 1 1 1 k", None, 0.0, 0.0);
|
||||
// The stroking colour too, and a colour selected with `cs`/`sc`.
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 K 0 0 0 1 k 50 w 25 0 m 25 100 l S", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "/DeviceCMYK cs 0 0 0 0.5 sc", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_cmyk_backdrop_colour_masks_by_its_gray() {
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[0 0 0 0]"), 0.0, 1.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0 k", Some("[0 0 0 1]"), 1.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[1 0 0 0]"), 0.0, 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_and_rgb_groups_mask_as_before() {
|
||||
assert_mask("/DeviceGray", "0 g", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceGray", "1 g", Some("[1]"), 1.0, 1.0);
|
||||
assert_mask("/DeviceGray", "0.5 g", None, 0.5, 0.0);
|
||||
assert_mask("/DeviceRGB", "0 0 0 rg", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceRGB", "1 1 1 rg", Some("[1 1 1]"), 1.0, 1.0);
|
||||
assert_mask("/DeviceRGB", "0.5 0.5 0.5 rg", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_cmyk_on_the_page_keeps_its_display_colour() {
|
||||
// Only a luminosity mask in a DeviceCMYK group reads CMYK as its gray. On the page, CMYK is
|
||||
// displayed through the CMYK profile as before, where 100% K is a dark grey.
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(),
|
||||
stream("", "0 0 0 1 k 0 0 100 100 re f"),
|
||||
];
|
||||
let (rgba, width) = render(pdf(&objects));
|
||||
let [r, g, b] = pixel(&rgba, width, 50, 50);
|
||||
assert!(r > 10 && g > 10 && b > 10 && r < 80, "100% K on the page: {:?}", [r, g, b]);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod cid_fallback_query;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
|
||||
@@ -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.
|
||||
@@ -3420,15 +3413,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref());
|
||||
if editing_content {
|
||||
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
|
||||
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
|
||||
view.move_background.paint(painter, &xf, i, info, &view.content, pointer);
|
||||
}
|
||||
if view.show_page_gaps {
|
||||
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
|
||||
}
|
||||
|
||||
// Comments: tools, selection, moving and resizing come before text selection.
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
if let QuickTool::Measure(measure_tool) = tool {
|
||||
crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units);
|
||||
}
|
||||
@@ -3437,7 +3429,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// every viewing mode); then comments; then text selection.
|
||||
if let QuickTool::Stamp(kind) = tool
|
||||
&& allowed
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
@@ -3461,7 +3453,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
if let Some(cs) = custom_stamp.as_ref()
|
||||
&& allowed
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
@@ -3482,12 +3474,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
|
||||
if let QuickTool::Fill(ft) = tool
|
||||
&& allowed
|
||||
&& !comments::fill_grabs(ui, &pcx, view)
|
||||
&& !comments::fill_grabs(&pcx, view)
|
||||
{
|
||||
match crate::fill_sign::page_input(
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
ft,
|
||||
@@ -3511,15 +3504,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
if matches!(tool, QuickTool::SignArea { .. }) {
|
||||
crate::sign_ui::page_input(ui, &resp, &xf, i, info, view);
|
||||
crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view);
|
||||
}
|
||||
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
|
||||
crate::zoom_snap::page_input(ui, &resp, &xf, i, view);
|
||||
crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view);
|
||||
}
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify);
|
||||
crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify);
|
||||
}
|
||||
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) {
|
||||
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) {
|
||||
view.current = i;
|
||||
open_boxes = true;
|
||||
}
|
||||
@@ -3531,7 +3524,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// While a table is being set up, nothing else is drawn or moved.
|
||||
let allowed = can_modify && table_preview.is_none() && open_table.is_none();
|
||||
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view);
|
||||
let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view);
|
||||
field_props |= o.properties;
|
||||
field_placed |= o.placed;
|
||||
open_table = open_table.or(o.table);
|
||||
@@ -3539,10 +3532,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
use crate::forms_ui::FieldPointer;
|
||||
let field = if tool == QuickTool::Select
|
||||
&& !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view))
|
||||
&& !comments::select_grabs(ui, &pcx, view)
|
||||
&& !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view))
|
||||
&& !comments::select_grabs(&pcx, view)
|
||||
{
|
||||
crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
|
||||
crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view)
|
||||
} else {
|
||||
FieldPointer::Away
|
||||
};
|
||||
@@ -3556,11 +3549,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let (vx, vy) = xf.screen_to_view(p);
|
||||
text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]))
|
||||
};
|
||||
crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view)
|
||||
crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view)
|
||||
};
|
||||
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view);
|
||||
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view);
|
||||
let on_objects =
|
||||
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view);
|
||||
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view);
|
||||
let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify {
|
||||
let generation = doc.edit_generation();
|
||||
match view.edit_lines.get(&i) {
|
||||
@@ -3592,6 +3585,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
&added,
|
||||
@@ -3620,11 +3614,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// Editable text takes precedence over background/overlapping artwork. Both
|
||||
// inventories are painted every frame, even when text owns the pointer.
|
||||
let text_used = crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
|
||||
let text_used =
|
||||
crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer);
|
||||
let image_used = crate::edit_text_ui::image_input(
|
||||
ui,
|
||||
&resp,
|
||||
&xf,
|
||||
&ptr,
|
||||
i,
|
||||
info,
|
||||
&images,
|
||||
@@ -3634,7 +3630,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
&& can_modify
|
||||
@@ -3712,23 +3708,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
} else if over_text && cursor_free {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
let press_here = ui
|
||||
.input(|inp| {
|
||||
inp.pointer.press_origin().or_else(|| {
|
||||
// A press and release can arrive in one pass; egui has already
|
||||
// cleared press_origin, but the press still starts multi-click selection.
|
||||
inp.events.iter().find_map(|event| match event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. }
|
||||
if pos.is_finite() =>
|
||||
{
|
||||
Some(*pos)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.is_some_and(|o| r.contains(o));
|
||||
let pressed = ui.input(|inp| inp.pointer.primary_pressed());
|
||||
// The pages' press, kept when its release came in the same frame (#2100): it
|
||||
// still starts multi-click selection.
|
||||
let press_here = ptr.origin.is_some_and(|o| r.contains(o));
|
||||
let pressed = ptr.pressed().is_some();
|
||||
if pressed && press_here && !over_link {
|
||||
let now = ui.input(|inp| inp.time);
|
||||
// The same limits egui uses to count clicks on release.
|
||||
@@ -3753,7 +3736,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// re-drag over text whose bar Copy closed would keep the bar hidden.
|
||||
view.actions_dismissed = None;
|
||||
view.selection_drag = true;
|
||||
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
let (ox, oy) = xf.screen_to_view(origin);
|
||||
if view.clicks < 2 {
|
||||
view.selection = if column {
|
||||
@@ -3843,11 +3826,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
crate::redact_ui::after_text(&resp, i, info, view);
|
||||
}
|
||||
if area_tool {
|
||||
crate::redact_ui::paint(ui, painter, i, view);
|
||||
crate::redact_ui::paint(painter, i, view, pointer);
|
||||
}
|
||||
comments::paint_page(ui, painter, &pcx, view);
|
||||
if editing_content {
|
||||
crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view);
|
||||
crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer);
|
||||
}
|
||||
if tool == QuickTool::EditText {
|
||||
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
|
||||
@@ -3856,38 +3839,39 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
crate::camera_ui::paint(painter, &xf, i, view);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view);
|
||||
crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer);
|
||||
}
|
||||
if preparing {
|
||||
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view);
|
||||
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer);
|
||||
if let Some(preview) = &table_preview {
|
||||
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
|
||||
}
|
||||
if calc_order {
|
||||
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
|
||||
if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) {
|
||||
if let Some(name) =
|
||||
crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer)
|
||||
{
|
||||
calc_hover = Some(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view);
|
||||
crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer);
|
||||
}
|
||||
|
||||
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
|
||||
// red border. Radio buttons are round, and so is theirs (as in Acrobat). Only what can
|
||||
// be filled is marked: a read-only widget (its field's, or a radio button's own) is
|
||||
// left as it is drawn.
|
||||
// red border. Radio buttons and round XFA check boxes are round, and so is theirs (as
|
||||
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
|
||||
// radio button's own) is left as it is drawn.
|
||||
if view.highlight_fields {
|
||||
for f in form.iter() {
|
||||
let required = f.has(pdfcraft_engine::field_flags::REQUIRED);
|
||||
let round = f.kind == pdfcraft_engine::FormFieldKind::Radio;
|
||||
for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) {
|
||||
let r = w.rect;
|
||||
let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]);
|
||||
let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
|
||||
let (width, color) =
|
||||
if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) };
|
||||
if round {
|
||||
if w.round {
|
||||
let radius = sr.width().min(sr.height()) / 2.0;
|
||||
painter.circle_filled(sr.center(), radius, tint);
|
||||
painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color));
|
||||
@@ -3898,9 +3882,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback.
|
||||
if !page_covered
|
||||
&& !matches!(tool, QuickTool::Fill(_))
|
||||
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips
|
||||
// draw above the palette, so none shows while it is open, even beside it.
|
||||
if !matches!(tool, QuickTool::Fill(_))
|
||||
&& unobstructed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
for l in info.links.iter().filter(|l| l.page == i) {
|
||||
@@ -4022,7 +4007,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
&& navigation_enabled
|
||||
&& unobstructed
|
||||
&& !single_paging
|
||||
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
|
||||
&& let Some(p) = ptr.pos
|
||||
{
|
||||
let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt));
|
||||
if dy != 0.0 {
|
||||
@@ -4307,7 +4292,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.frame_visible = visible_now.iter().copied().collect();
|
||||
|
||||
if let Some((pos, text)) = hover_text {
|
||||
egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
|
||||
// Click-through, as a tooltip is: the pages lose the pointer under anything that isn't.
|
||||
let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false);
|
||||
tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_max_width(320.0);
|
||||
ui.label(text);
|
||||
@@ -4729,6 +4716,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not
|
||||
/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found
|
||||
/// the view under the pointer.
|
||||
fn zoom_anchor(ui: &egui::Ui) -> Option<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 +5303,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.in_reply_to.is_none() && comments::made_by(a, tool))
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation {
|
||||
@@ -5877,6 +5889,8 @@ mod tests {
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [0.0; 4],
|
||||
color: None,
|
||||
index: 0,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -117,6 +118,10 @@ pub(crate) struct MoveBackground {
|
||||
key: Option<(u64, usize, usize)>,
|
||||
renderer: Option<pdfcraft_render::RenderPool>,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
/// The item alone on a transparent page: the pixels that follow the pointer. The page's own
|
||||
/// raster has whatever the item covers in its box too, which must stay where it is (#4464).
|
||||
item_renderer: Option<pdfcraft_render::RenderPool>,
|
||||
item: Option<egui::TextureHandle>,
|
||||
tag: Option<u64>,
|
||||
/// The item's display-space box when the gesture started.
|
||||
from: Option<[f64; 4]>,
|
||||
@@ -129,7 +134,12 @@ pub(crate) struct MoveBackground {
|
||||
|
||||
impl MoveBackground {
|
||||
pub fn pending(&self) -> bool {
|
||||
self.renderer.is_some() && self.texture.is_none()
|
||||
self.renderer.is_some() && self.item_renderer.is_some() && (self.texture.is_none() || self.item.is_none())
|
||||
}
|
||||
|
||||
/// Both rasters are in: the page without the item, and the item alone.
|
||||
fn ready(&self) -> bool {
|
||||
self.texture.is_some() && self.item.is_some()
|
||||
}
|
||||
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) {
|
||||
@@ -144,6 +154,7 @@ impl MoveBackground {
|
||||
*self = Self { key, ..Default::default() };
|
||||
if let Some((_, page, index)) = key {
|
||||
self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer());
|
||||
self.item_renderer = doc.added_content_alone(page, index).ok().map(|p| p.renderer());
|
||||
self.from = on_page(doc.added(), page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect());
|
||||
self.picture = picture(page, index);
|
||||
}
|
||||
@@ -151,39 +162,60 @@ impl MoveBackground {
|
||||
if self.settling.is_some() {
|
||||
return;
|
||||
}
|
||||
let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return };
|
||||
let (Some((_, page, _)), Some(renderer), Some(item_renderer)) = (self.key, self.renderer.as_ref(), self.item_renderer.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let Some(p) = doc.info.pages.get(page) else { return };
|
||||
let max_side = ctx.input(|i| i.max_texture_side) as f32;
|
||||
let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0));
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if self.tag != Some(tag) {
|
||||
renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
|
||||
for r in [renderer, item_renderer] {
|
||||
r.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
|
||||
}
|
||||
self.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = renderer.try_recv() {
|
||||
if self.receive(ctx, tag) && self.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in the rasters rendered at `tag`. `false` when either failed: the preview is then
|
||||
/// dropped whole, including rasters in from an earlier scale.
|
||||
fn receive(&mut self, ctx: &egui::Context, tag: u64) -> bool {
|
||||
let (Some(renderer), Some(item_renderer)) = (self.renderer.as_ref(), self.item_renderer.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let mut failed = false;
|
||||
for (r, slot, name) in
|
||||
[(renderer, &mut self.texture, "added-content-move-background"), (item_renderer, &mut self.item, "added-content-move-item")]
|
||||
{
|
||||
let Some(out) = r.try_recv() else { continue };
|
||||
if out.error.is_some() {
|
||||
// Without a background the item stays put while dragged, as before.
|
||||
self.renderer = None;
|
||||
return;
|
||||
}
|
||||
if out.request.tag == tag {
|
||||
self.texture = Some(ctx.load_texture(
|
||||
"added-content-move-background",
|
||||
failed = true;
|
||||
} else if out.request.tag == tag {
|
||||
*slot = Some(ctx.load_texture(
|
||||
name,
|
||||
crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba),
|
||||
egui::TextureOptions::LINEAR,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
if failed {
|
||||
// Without both rasters the item stays put while dragged, as before.
|
||||
self.renderer = None;
|
||||
self.item_renderer = None;
|
||||
self.texture = None;
|
||||
self.item = None;
|
||||
}
|
||||
!failed
|
||||
}
|
||||
|
||||
/// The move was applied: keep the preview, now at the new generation, until the page is
|
||||
/// rendered again. Any other edit ends it.
|
||||
pub fn committed(&mut self, edit: &Edit, generation: u64) {
|
||||
match edit {
|
||||
Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
|
||||
Edit::UpdateContent { page, index, content } if self.ready() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
|
||||
self.key = Some((generation, *page, *index));
|
||||
self.settling = Some(content.rect());
|
||||
}
|
||||
@@ -197,26 +229,17 @@ impl MoveBackground {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page without the moving item, then the item's pixels taken from `page_tex` (the page
|
||||
/// as rendered, item included where it was) at the dragged or applied position.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&self,
|
||||
ui: &egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
cv: &ContentView,
|
||||
page_tex: Option<egui::TextureId>,
|
||||
) {
|
||||
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
|
||||
/// The page without the moving item, then the item's own pixels (from its raster alone, so
|
||||
/// nothing it covered comes along) at the dragged (to the pages' `pointer`) or applied
|
||||
/// position.
|
||||
pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, pointer: Option<Pos2>) {
|
||||
let (Some((_, kp, _)), Some(background), Some(item), Some(from)) = (self.key, &self.texture, &self.item, self.from) else { return };
|
||||
if kp != page {
|
||||
return;
|
||||
}
|
||||
let r = screen_rect(xf, info, page, from);
|
||||
let to = if let Some((_, grab, start)) = cv.grab {
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
|
||||
let Some(p) = pointer else { return };
|
||||
dragged(r, grab, p - start, self.picture)
|
||||
} else if let Some(to) = self.settling {
|
||||
let to = screen_rect(xf, info, page, to);
|
||||
@@ -229,7 +252,7 @@ impl MoveBackground {
|
||||
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
// A pixel around the box, so its edges come along.
|
||||
let (r, to) = (r.expand(1.0), to.expand(1.0));
|
||||
let mut mesh = egui::Mesh::with_texture(page_tex);
|
||||
let mut mesh = egui::Mesh::with_texture(item.id());
|
||||
for (a, b) in [
|
||||
(r.left_top(), to.left_top()),
|
||||
(r.right_top(), to.right_top()),
|
||||
@@ -302,14 +325,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
|
||||
}
|
||||
|
||||
/// Added text and images keep pointer ownership over a form field while editing content.
|
||||
pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
|
||||
pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
|
||||
let cv = &view.content;
|
||||
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
|
||||
return true;
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(p) = ptr.within(xf.rect) else { return false };
|
||||
on_page(added, page).into_iter().any(|(index, item)| {
|
||||
let rect = screen_rect(xf, info, page, item.content.rect());
|
||||
rect.expand(2.0).contains(p)
|
||||
@@ -324,6 +345,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
added: &[Added],
|
||||
@@ -333,13 +355,13 @@ pub(crate) fn page_input(
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let mut consumed = false;
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
|
||||
let pointer = ptr.pos;
|
||||
let cv = &mut view.content;
|
||||
if let Some((gp, grab, start)) = cv.grab
|
||||
&& gp == page
|
||||
{
|
||||
consumed = true;
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
cv.grab = None;
|
||||
let end = pointer.unwrap_or(start);
|
||||
if let Some((sp, si)) = cv.selected
|
||||
@@ -408,7 +430,7 @@ pub(crate) fn page_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) {
|
||||
cv.grab = Some((page, Grab::Corner(left, top), origin));
|
||||
consumed = true;
|
||||
@@ -450,10 +472,18 @@ pub(crate) fn page_input(
|
||||
consumed || hit.is_some() && resp.is_pointer_button_down_on()
|
||||
}
|
||||
|
||||
/// Outlines of added items (hover and selection with handles) and the box being dragged.
|
||||
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) {
|
||||
/// Outlines of added items (hover and selection with handles) and the box being dragged, the
|
||||
/// pages' `pointer` over them.
|
||||
pub(crate) fn paint_page(
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
added: &[Added],
|
||||
view: &DocView,
|
||||
pointer: Option<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;
|
||||
@@ -935,19 +965,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted));
|
||||
let mut crop = img.crop.map(|v| (v * 100.0).round());
|
||||
let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0;
|
||||
// Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file.
|
||||
let shown = img.crop.map(|v| v * 100.0);
|
||||
let mut crop = shown;
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
|
||||
ui.label(label);
|
||||
// Applied when the drag ends, so a drag is one undo step.
|
||||
let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%"));
|
||||
// Up to what the opposite side leaves. Applied when the drag ends: one undo step.
|
||||
let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0);
|
||||
let Some(side) = crop.get_mut(k) else { continue };
|
||||
let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%"));
|
||||
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let mut i = img.clone();
|
||||
i.crop = crop.map(|v| v / 100.0);
|
||||
for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) {
|
||||
if now != was {
|
||||
*stored = now / 100.0;
|
||||
}
|
||||
}
|
||||
if i.crop != img.crop {
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i)));
|
||||
}
|
||||
@@ -1015,6 +1054,35 @@ mod tests {
|
||||
assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom");
|
||||
}
|
||||
|
||||
/// #4464: both rasters were in at one scale and a render at a new scale failed. The rasters
|
||||
/// from the old scale must go too, or the item keeps following the pointer although the
|
||||
/// preview gave up.
|
||||
#[test]
|
||||
fn failed_rerender_drops_the_rasters_from_the_earlier_scale() {
|
||||
let ctx = egui::Context::default();
|
||||
let texture = |name| Some(ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), egui::TextureOptions::LINEAR));
|
||||
// Bytes no parser accepts: every render answers with an error.
|
||||
let pool = || {
|
||||
let pool = pdfcraft_render::RenderPool::new_inline(std::sync::Arc::new(b"not a pdf".to_vec()), Default::default());
|
||||
pool.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag: 2000, ..Default::default() }]);
|
||||
pool
|
||||
};
|
||||
let mut mb = MoveBackground {
|
||||
key: Some((0, 0, 0)),
|
||||
renderer: Some(pool()),
|
||||
texture: texture("background"),
|
||||
item_renderer: Some(pool()),
|
||||
item: texture("item"),
|
||||
tag: Some(2000),
|
||||
from: Some([0.0, 0.0, 10.0, 10.0]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(mb.ready(), "both rasters from the earlier scale are in");
|
||||
assert!(!mb.receive(&ctx, 2000), "the new scale's renders fail");
|
||||
assert!(mb.texture.is_none() && mb.item.is_none(), "no raster is left to draw the moving item from");
|
||||
assert!(!mb.ready() && !mb.pending());
|
||||
}
|
||||
|
||||
/// #4245: the page counts the spacing after a line's last character too, so a line that fits
|
||||
/// only without it wraps in the saved text and must wrap in the editor.
|
||||
#[test]
|
||||
|
||||
@@ -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,
|
||||
@@ -497,6 +501,7 @@ fn crop_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
view: &mut DocView,
|
||||
@@ -506,12 +511,12 @@ fn crop_input(
|
||||
) -> bool {
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ui.input(|inp| inp.pointer.hover_pos());
|
||||
let hover_pos = ptr.pos;
|
||||
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
|
||||
// A drag in progress moves a grip; the box stays inside the picture and at least 6 px.
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = ui.input(|inp| inp.pointer.press_origin())
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let k = keep_screen(mode.keep);
|
||||
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8));
|
||||
@@ -558,7 +563,7 @@ fn crop_input(
|
||||
max.x = max.x.clamp(min.x + min_w, b.max.x);
|
||||
max.y = max.y.clamp(min.y + min_h, b.max.y);
|
||||
mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max));
|
||||
if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
mode.drag = None;
|
||||
}
|
||||
}
|
||||
@@ -664,7 +669,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
|
||||
|
||||
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
|
||||
/// applies after the pointer leaves the image/page while dragging.
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
|
||||
if selection.drag.is_some() {
|
||||
return true;
|
||||
@@ -672,7 +677,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf
|
||||
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
|
||||
let Some(rect) = image.visible_rect else { return false };
|
||||
let b = xf.user_rect(info, page, rect.map(|v| v as f32));
|
||||
let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() });
|
||||
let p = if ptr.primary_down { ptr.origin } else { ptr.pos };
|
||||
let knob = rotate_knob(b, ui.clip_rect()).0;
|
||||
p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0))
|
||||
}
|
||||
@@ -800,6 +805,7 @@ pub(crate) fn image_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
images: &[pdfcraft_engine::PageImage],
|
||||
@@ -809,7 +815,7 @@ pub(crate) fn image_input(
|
||||
) -> bool {
|
||||
let boxes: Vec<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 };
|
||||
@@ -853,7 +859,7 @@ pub(crate) fn image_input(
|
||||
if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) {
|
||||
let b = boxes[i];
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) {
|
||||
return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction);
|
||||
return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction);
|
||||
}
|
||||
let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()];
|
||||
let handles = |rect: Rect| {
|
||||
@@ -903,10 +909,9 @@ pub(crate) fn image_input(
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = origin
|
||||
&& let Some(o) = ptr.origin
|
||||
&& o.distance(knob) < 8.0
|
||||
{
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
@@ -915,7 +920,7 @@ pub(crate) fn image_input(
|
||||
}
|
||||
} else if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = origin
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
|
||||
if corner.is_some() || image_contains(i, o) {
|
||||
@@ -942,7 +947,7 @@ pub(crate) fn image_input(
|
||||
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
|
||||
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
s.turning = false;
|
||||
@@ -1000,7 +1005,7 @@ pub(crate) fn image_input(
|
||||
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
handles(preview);
|
||||
}
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
let norm = |r: Rect| {
|
||||
@@ -1194,6 +1199,7 @@ fn open_editor(
|
||||
max_width,
|
||||
size: (l.size as f32 * scale).clamp(8.0, 72.0),
|
||||
focus: true,
|
||||
had_focus: false,
|
||||
look: look_of(l),
|
||||
look0: look_of(l),
|
||||
family_chosen: false,
|
||||
@@ -1209,6 +1215,7 @@ pub(crate) fn page_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
lines: &[pdfcraft_engine::TextBlock],
|
||||
@@ -1256,7 +1263,7 @@ pub(crate) fn page_input(
|
||||
if let Some(d) = view.block_drag.filter(|d| d.page == page)
|
||||
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
|
||||
{
|
||||
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
|
||||
let p = ptr.pos.unwrap_or(d.start);
|
||||
let dx = p.x - d.start.x;
|
||||
let preview = match d.grip {
|
||||
Grip::Move => b.translate(p - d.start),
|
||||
@@ -1269,7 +1276,7 @@ pub(crate) fn page_input(
|
||||
draw_handles(preview);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
|
||||
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
|
||||
if resp.drag_stopped() || !ptr.any_down {
|
||||
view.block_drag = None;
|
||||
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
|
||||
let edit = if resizing {
|
||||
@@ -1301,13 +1308,13 @@ pub(crate) fn page_input(
|
||||
// quick flick has left the edge by then.
|
||||
if resp.drag_started()
|
||||
&& active.is_none()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(o) = ptr.origin
|
||||
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
|
||||
{
|
||||
view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip });
|
||||
return true;
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let Some(p) = ptr.within(xf.rect) else { return false };
|
||||
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
|
||||
if active != Some(hit) {
|
||||
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
@@ -1553,8 +1560,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
|
||||
|
||||
/// The inline editor; returns the edit once the text is applied.
|
||||
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
|
||||
// Clicks outside the document (the Format text panel) keep the paragraph open.
|
||||
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
|
||||
// Clicks outside the document (the Format text panel) keep the paragraph open. Where the
|
||||
// click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts.
|
||||
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
|
||||
let page = view.line_editor.as_ref()?.page;
|
||||
let xf = view.page_xform(page)?;
|
||||
let hold = view.objects.hold_editor;
|
||||
@@ -1700,11 +1708,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
r.request_focus();
|
||||
ed.focus = false;
|
||||
}
|
||||
let lost = lost_focus_once(&r, &mut ed.had_focus);
|
||||
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
|
||||
if esc {
|
||||
done = Some(false);
|
||||
} else if apply || (r.lost_focus() && !outside && !hold) {
|
||||
} else if apply || (lost && !outside && !hold) {
|
||||
done = Some(true);
|
||||
}
|
||||
});
|
||||
@@ -1739,6 +1748,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option<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());
|
||||
@@ -1796,6 +1815,7 @@ mod tests {
|
||||
max_width: 400.0,
|
||||
size: block.size as f32,
|
||||
focus: true,
|
||||
had_focus: false,
|
||||
look: look_of(&block),
|
||||
look0: look_of(&block),
|
||||
family_chosen: false,
|
||||
|
||||
@@ -160,7 +160,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
Edit::AddAnnotation(a) => {
|
||||
// Select the new comment (appended last among the page's comments).
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max();
|
||||
view.comments.selected = newest.map(|n| (a.page, n));
|
||||
view.comments.reveal = true;
|
||||
// A new highlight opens its note for typing straight away, as in Acrobat,
|
||||
@@ -509,7 +509,7 @@ impl PdfCraftApp {
|
||||
let newest = self
|
||||
.session
|
||||
.get(view.id)
|
||||
.and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && a.in_reply_to.is_none()).map(|a| a.index).max());
|
||||
.and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max());
|
||||
view.comments.selected = newest.map(|index| (page, index));
|
||||
view.comments.reveal = true;
|
||||
self.quick_tool = crate::QuickTool::Select;
|
||||
@@ -1615,6 +1615,7 @@ mod revert_draft_tests {
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
File diff suppressed because it is too large
Load Diff
@@ -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.
|
@@ -159,7 +159,7 @@ fn plural_sl(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 34] = [
|
||||
pub static LANGUAGES: [LangInfo; 35] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
|
||||
@@ -215,6 +215,10 @@ pub static LANGUAGES: [LangInfo; 34] = [
|
||||
LangInfo {
|
||||
code: "ckb", name: "کوردیی ناوەندی", source: include_str!("ckb.tsv"), plural: plural_one_other, rtl: true, catalog: OnceLock::new()
|
||||
},
|
||||
// Persian (Farsi); every `fa-*` locale (`fa-IR`, `fa_IR.UTF-8`, `fa-AF` ...) resolves here. Right
|
||||
// to left like Arabic: see `LangInfo::rtl`. CLDR `fa` counts have two forms (one: i = 0 or n = 1,
|
||||
// other), which for whole counts is the French rule, so `@plural` entries list two forms.
|
||||
LangInfo { code: "fa", name: "فارسی", source: include_str!("fa.tsv"), plural: plural_fr, rtl: true, catalog: OnceLock::new() },
|
||||
// Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here.
|
||||
LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() },
|
||||
// Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other).
|
||||
@@ -1672,7 +1676,7 @@ mod tests {
|
||||
assert_eq!(ar.name(), "العربية");
|
||||
assert!(ar.rtl());
|
||||
let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect();
|
||||
assert_eq!(rtl, ["ar", "ckb"], "Arabic and Kurdish are the right-to-left languages");
|
||||
assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages");
|
||||
assert_eq!(normalize_pref("AR"), Some("ar"));
|
||||
for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
|
||||
@@ -1723,6 +1727,39 @@ mod tests {
|
||||
assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]);
|
||||
}
|
||||
|
||||
fn persian() -> Lang {
|
||||
Lang::from_code("fa").expect("fa registered")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_is_registered_and_persists() {
|
||||
let fa = persian();
|
||||
assert_eq!(fa.name(), "فارسی");
|
||||
assert!(fa.rtl());
|
||||
// CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural.
|
||||
assert_eq!(fa.0.plural_forms(), 2);
|
||||
assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]);
|
||||
assert_eq!(normalize_pref("FA"), Some("fa"));
|
||||
for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(fa), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa));
|
||||
// Lookups return Persian (single words in display order).
|
||||
assert_eq!(tr(fa, "Save"), "ذخیره");
|
||||
assert_eq!(tr(fa, "Cancel"), "لغو");
|
||||
assert_eq!(tr(fa, "File"), "فایل");
|
||||
// Unknown strings and user text are never reordered by a lookup.
|
||||
assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf");
|
||||
let mut app = crate::PdfCraftApp::default();
|
||||
app.set_option("language", "FA").unwrap();
|
||||
assert_eq!(app.language, "fa");
|
||||
let mut restored = crate::PdfCraftApp::default();
|
||||
restored.restore(&app.persist());
|
||||
assert_eq!(restored.language, "fa");
|
||||
assert!(restored.set_option("language", "xx").is_err());
|
||||
assert_eq!(restored.language, "fa");
|
||||
}
|
||||
|
||||
/// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface
|
||||
/// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps
|
||||
/// it; Arabic labels must not need a Japanese face).
|
||||
@@ -1794,6 +1831,7 @@ mod tests {
|
||||
section.items.iter().for_each(|item| add(item.label));
|
||||
}
|
||||
}
|
||||
pdfcraft_engine::catalog::TOOL_CATEGORIES.iter().for_each(|category| add(category.label));
|
||||
let engine: [&[&str]; 9] = [
|
||||
pdfcraft_engine::a11y::WORDINGS,
|
||||
pdfcraft_engine::a11y::REPORT_WORDS,
|
||||
|
||||
@@ -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.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user