mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
M0: reconcile main and preserve egui 0.37 attachment-menu tests
This commit is contained in:
commit
9032aeb35f
105 files changed
+6258
-416
No files matched your search
+2
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
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 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -28,6 +28,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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 |
|
||||
|
||||
@@ -212,6 +212,16 @@ 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"
|
||||
|
||||
Generated
+36
-36
@@ -3963,7 +3963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
@@ -3989,7 +3989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4001,7 +4001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4012,7 +4012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4023,7 +4023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4045,7 +4045,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4063,11 +4063,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",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4106,7 +4106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4118,7 +4118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4133,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4174,14 +4174,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",
|
||||
@@ -4191,7 +4191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4201,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4214,11 +4214,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",
|
||||
@@ -4226,7 +4226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4239,14 +4239,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",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4271,11 +4271,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",
|
||||
@@ -4286,7 +4286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4307,7 +4307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4319,7 +4319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4335,7 +4335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4354,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4390,11 +4390,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",
|
||||
@@ -4443,7 +4443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4455,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4469,7 +4469,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,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -627,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"],
|
||||
@@ -956,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());
|
||||
}
|
||||
@@ -4471,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(_))));
|
||||
@@ -5842,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");
|
||||
@@ -6848,12 +6889,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an
|
||||
/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves.
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() {
|
||||
let dir = workdir("italic-run-word");
|
||||
let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \
|
||||
BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("opinion.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" }));
|
||||
let zip = std::fs::read(dir.join("opinion.docx")).unwrap();
|
||||
let mut at = 0;
|
||||
let mut xml = String::new();
|
||||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||||
let data = at + 30 + len;
|
||||
if &zip[at + 30..data] == b"word/document.xml" {
|
||||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||||
break;
|
||||
}
|
||||
at = data + size;
|
||||
}
|
||||
// The paragraphs that hold text, each as its runs: (text, italic).
|
||||
let paragraphs: Vec<Vec<(String, bool)>> = xml
|
||||
.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:r>")
|
||||
.skip(1)
|
||||
.filter_map(|r| Some((r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0.to_string(), r.contains("<w:i/>"))))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|runs| !runs.is_empty())
|
||||
.collect();
|
||||
let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}"));
|
||||
assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}");
|
||||
assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::<String>(), "Before Chevron after.", "{xml}");
|
||||
assert_eq!(sentence.iter().map(|(_, i)| *i).collect::<Vec<_>>(), [false, true, false], "{xml}");
|
||||
assert_eq!(paragraphs.len(), 2, "{xml}");
|
||||
}
|
||||
|
||||
/// 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_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();
|
||||
@@ -7276,7 +7384,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 }));
|
||||
@@ -7301,7 +7410,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 }));
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -220,7 +220,10 @@ impl crate::Document {
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
.map(|b| {
|
||||
let f = b.base_font.to_ascii_lowercase();
|
||||
// `text` is trimmed; the editor text keeps the source's own spaces.
|
||||
let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)];
|
||||
pdfcraft_export::Block {
|
||||
space_around,
|
||||
text: b.text,
|
||||
rect: visible_rect(b.rect),
|
||||
size: b.size * unit,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+52
-12
@@ -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;
|
||||
@@ -3237,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.
|
||||
@@ -3245,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.
|
||||
@@ -3481,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;
|
||||
@@ -3524,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
|
||||
@@ -3544,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`).
|
||||
@@ -3569,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
|
||||
@@ -3641,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.
|
||||
@@ -3770,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;
|
||||
@@ -3954,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();
|
||||
@@ -3963,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();
|
||||
@@ -4060,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))
|
||||
|
||||
+177
-8
@@ -3650,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]
|
||||
@@ -5580,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.
|
||||
@@ -5663,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>";
|
||||
@@ -5682,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
|
||||
@@ -5840,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();
|
||||
@@ -5869,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;
|
||||
@@ -5891,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()]);
|
||||
@@ -5901,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.
|
||||
@@ -8550,3 +8652,70 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
+339
-33
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
|
||||
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
|
||||
//! document: headings are the paragraphs set larger than the body text. Word keeps pages with
|
||||
//! document: headings are the paragraphs set larger than the body text, and a block that
|
||||
//! continues another on its line (an italic word, a link) is a run of that block's paragraph.
|
||||
//! Word keeps pages with
|
||||
//! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable
|
||||
//! shapes to avoid reflowing overlays; other pages flow with images inline.
|
||||
|
||||
@@ -30,6 +32,10 @@ pub struct Block {
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in user space (degrees).
|
||||
pub rotation: f64,
|
||||
/// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`,
|
||||
/// which `text` leaves out. A block that continues this one on its line is a run of the same
|
||||
/// paragraph, and these say whether a space separates them (#4158).
|
||||
pub space_around: [bool; 2],
|
||||
}
|
||||
|
||||
/// An image of a page.
|
||||
@@ -81,7 +87,8 @@ pub struct Table {
|
||||
|
||||
/// What goes into the output, in order.
|
||||
enum Item<'a> {
|
||||
Para(&'a Block, u8),
|
||||
/// A paragraph's runs (at least one) and its heading level.
|
||||
Para(Vec<Run<'a>>, u8),
|
||||
/// Owned: tables are detected per page inside [`items`], so they cannot borrow.
|
||||
Table(Table),
|
||||
Img(&'a Image),
|
||||
@@ -149,6 +156,12 @@ const CONTINUATION_PROBE: usize = 64;
|
||||
/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running
|
||||
/// on (a link inside a sentence, a superscript), not a cell of its own.
|
||||
fn continuations(blocks: &[Block]) -> Vec<bool> {
|
||||
continued_from(blocks).into_iter().map(|from| from.is_some()).collect()
|
||||
}
|
||||
|
||||
/// Per block, the block it continues on the same line (see [`continuations`]): of those it could
|
||||
/// continue, the one that ends nearest to where it starts.
|
||||
fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
|
||||
let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect();
|
||||
by_end.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
blocks
|
||||
@@ -162,11 +175,88 @@ fn continuations(blocks: &[Block]) -> Vec<bool> {
|
||||
.iter()
|
||||
.take_while(|(end, _)| *end <= b.rect[0] + 1.0)
|
||||
.take(CONTINUATION_PROBE)
|
||||
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi))
|
||||
.any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
|
||||
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a)))
|
||||
.filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
|
||||
.min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs()))
|
||||
.map(|(ai, _)| ai)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A gap between two runs wider than this (as a fraction of the font size) is a word space the
|
||||
/// text left out. It is the gap the reading-block grouping uses for the same purpose.
|
||||
const WORD_GAP: f64 = 0.12;
|
||||
|
||||
/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space
|
||||
/// before the next run when the source had one there or the page shows a gap, but the text has
|
||||
/// none).
|
||||
pub(crate) struct Run<'a> {
|
||||
pub(crate) block: &'a Block,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
/// A page's paragraphs, each a list of runs from left to right along its line. A block that
|
||||
/// continues another on the same line ([`continued_from`]: an italic case name, a link, a
|
||||
/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its
|
||||
/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join
|
||||
/// nothing. Paragraphs come in the order of their first runs in `blocks`.
|
||||
pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec<Vec<Run<'a>>> {
|
||||
let skipped = |i: usize| skip.get(i).copied().unwrap_or(false);
|
||||
let from = continued_from(blocks);
|
||||
// Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s
|
||||
// order, NaN included), so that one is visited first and has its paragraph already, and the
|
||||
// links cannot form a cycle.
|
||||
let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect();
|
||||
order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
// Each block's paragraph, named by its first block, and each paragraph's blocks in order.
|
||||
let mut head: Vec<usize> = (0..blocks.len()).collect();
|
||||
let mut members: Vec<Vec<usize>> = vec![Vec::new(); blocks.len()];
|
||||
for &(x0, bi) in &order {
|
||||
let Some(b) = blocks.get(bi) else { continue };
|
||||
let joined = from
|
||||
.get(bi)
|
||||
.copied()
|
||||
.flatten()
|
||||
.filter(|ai| !skipped(*ai))
|
||||
.filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1))
|
||||
.and_then(|ai| head.get(ai).copied());
|
||||
let h = joined.unwrap_or(bi);
|
||||
if let Some(slot) = head.get_mut(bi) {
|
||||
*slot = h;
|
||||
}
|
||||
if let Some(m) = members.get_mut(h) {
|
||||
m.push(bi);
|
||||
}
|
||||
}
|
||||
members
|
||||
.into_iter()
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| {
|
||||
let mut runs: Vec<Run<'a>> = Vec::with_capacity(m.len());
|
||||
for b in m.into_iter().filter_map(|bi| blocks.get(bi)) {
|
||||
if let Some(prev) = runs.last_mut()
|
||||
&& !prev.text.ends_with(char::is_whitespace)
|
||||
&& !b.text.starts_with(char::is_whitespace)
|
||||
&& (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP)
|
||||
{
|
||||
prev.text.push(' ');
|
||||
}
|
||||
runs.push(Run { block: b, text: b.text.clone() });
|
||||
}
|
||||
runs
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold
|
||||
/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run
|
||||
/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker.
|
||||
fn para_level(runs: &[Run], body: f64) -> u8 {
|
||||
let Some(first) = runs.first() else { return 0 };
|
||||
let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3;
|
||||
runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
|
||||
fn is_cell_like(b: &Block) -> bool {
|
||||
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
|
||||
@@ -374,12 +464,14 @@ fn items(pages: &[Page]) -> Vec<Item<'_>> {
|
||||
}
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
// Blocks and images by their top edge, top to bottom.
|
||||
let mut parts: Vec<(f64, f64, Item)> = p
|
||||
.blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false))
|
||||
.map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body))))
|
||||
// A paragraph sits where its first run does: its other runs follow along the same line.
|
||||
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
|
||||
.into_iter()
|
||||
.filter_map(|runs| {
|
||||
let first = runs.first()?.block;
|
||||
let lvl = para_level(&runs, body);
|
||||
Some((first.rect[3], first.rect[0], Item::Para(runs, lvl)))
|
||||
})
|
||||
.collect();
|
||||
parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t))));
|
||||
parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im))));
|
||||
@@ -434,15 +526,26 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
);
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
Item::Para(b, lvl) => {
|
||||
let mut t = esc(&b.text);
|
||||
if b.italic {
|
||||
t = format!("<em>{t}</em>");
|
||||
Item::Para(runs, lvl) => {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
let mut t = String::new();
|
||||
for r in &runs {
|
||||
let mut run = esc(&r.text);
|
||||
if r.block.italic {
|
||||
run = format!("<em>{run}</em>");
|
||||
}
|
||||
if r.block.bold && lvl == 0 {
|
||||
run = format!("<strong>{run}</strong>");
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
if b.bold && lvl == 0 {
|
||||
t = format!("<strong>{t}</strong>");
|
||||
}
|
||||
let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
match lvl {
|
||||
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
||||
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
||||
@@ -711,7 +814,6 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
let mut media: Vec<(String, &Image)> = Vec::new();
|
||||
// Word needs a paragraph between adjacent tables and after the last one in the body.
|
||||
let mut after_table = false;
|
||||
let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color);
|
||||
let mut shape_id = 0usize;
|
||||
for (page_index, page) in pages.iter().enumerate() {
|
||||
let (pw, _) = word_page_size(page);
|
||||
@@ -722,13 +824,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
} else {
|
||||
for it in items(std::slice::from_ref(page)) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
Item::Para(runs, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
body.push_str("<w:p>");
|
||||
body.push_str(style);
|
||||
for r in runs {
|
||||
body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
||||
}
|
||||
body.push_str("</w:p>");
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
@@ -873,7 +980,7 @@ pub fn rtf(pages: &[Page]) -> String {
|
||||
};
|
||||
for it in &items {
|
||||
match it {
|
||||
Item::Para(b, _) => note(b.color),
|
||||
Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)),
|
||||
Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)),
|
||||
Item::Img(_) | Item::PageBreak => {}
|
||||
}
|
||||
@@ -894,16 +1001,32 @@ pub fn rtf(pages: &[Page]) -> String {
|
||||
}
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Para(b, _) => {
|
||||
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
|
||||
if b.bold {
|
||||
fmt.push_str("\\b");
|
||||
Item::Para(runs, _) => {
|
||||
// The first run's formatting is the paragraph's; each later run is a group that
|
||||
// turns on or off what differs from it.
|
||||
let fmt = |b: &Block, later: bool| {
|
||||
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
|
||||
if b.bold {
|
||||
fmt.push_str("\\b");
|
||||
} else if later {
|
||||
fmt.push_str("\\b0");
|
||||
}
|
||||
if b.italic {
|
||||
fmt.push_str("\\i");
|
||||
} else if later {
|
||||
fmt.push_str("\\i0");
|
||||
}
|
||||
let colour = cf(b.color);
|
||||
fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour });
|
||||
fmt
|
||||
};
|
||||
let mut runs = runs.iter();
|
||||
let Some(first) = runs.next() else { continue };
|
||||
s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text)));
|
||||
for r in runs {
|
||||
s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text)));
|
||||
}
|
||||
if b.italic {
|
||||
fmt.push_str("\\i");
|
||||
}
|
||||
fmt.push_str(&cf(b.color));
|
||||
s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text)));
|
||||
s.push_str("\\par}\n");
|
||||
}
|
||||
Item::Table(t) => {
|
||||
let mut edges = t.cols.clone();
|
||||
@@ -957,6 +1080,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
})
|
||||
.collect();
|
||||
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
|
||||
@@ -995,6 +1119,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -1098,6 +1223,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.8; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
p.images = page().images;
|
||||
let xml = part(&docx(&[p], "Invoice"), "word/document.xml");
|
||||
@@ -1184,6 +1310,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
|
||||
assert!(xml.contains(">Total 4.50\tGBP</w:t>"), "{xml}");
|
||||
@@ -1237,6 +1364,7 @@ mod tests {
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
let r = rtf(&[p]);
|
||||
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
|
||||
@@ -1254,6 +1382,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -1277,6 +1406,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
],
|
||||
images: Vec::new(),
|
||||
@@ -1329,7 +1459,16 @@ mod tests {
|
||||
.map(|i| {
|
||||
let (row, col) = (i / 2, i / 2 + i % 2);
|
||||
let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
Block {
|
||||
text: "x".into(),
|
||||
rect: [x, y, x + 8.0, y + 12.0],
|
||||
size: 11.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
@@ -1356,6 +1495,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1370,7 +1510,16 @@ mod tests {
|
||||
}
|
||||
|
||||
fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block {
|
||||
Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
Block {
|
||||
text: t.into(),
|
||||
rect: [x0, y0, x1, y1],
|
||||
size: 12.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// #2806: a sentence whose link is set in another colour or font runs on as separate blocks
|
||||
@@ -1400,6 +1549,8 @@ mod tests {
|
||||
};
|
||||
let h = html(std::slice::from_ref(&p), "Regulations");
|
||||
assert!(!h.contains("<table>"), "running text became a table: {h}");
|
||||
// The link is a run of its sentence, in its own colour (#4158).
|
||||
assert!(h.contains("<p>6.3. 2. All content shown at <span style=\"color:#0563C1\">http</span>"), "{h}");
|
||||
for b in &p.blocks {
|
||||
assert!(h.contains(&b.text), "{} was lost: {h}", b.text);
|
||||
}
|
||||
@@ -1487,6 +1638,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let p = Page {
|
||||
width: 612.0,
|
||||
@@ -1541,4 +1693,158 @@ mod tests {
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
|
||||
}
|
||||
|
||||
/// The flowing paragraphs of a Word document (table cells left out), each as its runs' text
|
||||
/// and whether the run is italic.
|
||||
fn word_paragraphs(xml: &str) -> Vec<Vec<(String, bool)>> {
|
||||
let body = xml.split("<w:tbl>").map(|s| s.split_once("</w:tbl>").map_or(s, |(_, after)| after)).collect::<String>();
|
||||
body.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:r>")
|
||||
.skip(1)
|
||||
.filter_map(|r| {
|
||||
let text = r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0;
|
||||
Some((text.to_string(), r.contains("<w:i/>")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|runs| !runs.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> {
|
||||
r.iter().map(|(t, i)| (t.to_string(), *i)).collect()
|
||||
}
|
||||
|
||||
/// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman
|
||||
/// text after it, the way reading blocks split a sentence at its style changes (#4158). The
|
||||
/// italic face stands a little taller than the roman one around it.
|
||||
fn italic_sentence() -> Vec<Block> {
|
||||
vec![
|
||||
block("Before", 72.0, 700.0, 110.0, 712.0),
|
||||
Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) },
|
||||
block("after.", 161.0, 700.0, 190.0, 712.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a
|
||||
/// run in its own style, in every Office format.
|
||||
#[test]
|
||||
fn an_italic_word_stays_in_its_sentence() {
|
||||
let mut blocks = italic_sentence();
|
||||
blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0));
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
let h = html(std::slice::from_ref(&p), "Opinion");
|
||||
assert!(h.contains("<p>Before <em>Chevron </em>after.</p>"), "{h}");
|
||||
let r = rtf(std::slice::from_ref(&p));
|
||||
assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}");
|
||||
// The longest line is the slide's title; the sentence is one paragraph of the body.
|
||||
let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml");
|
||||
assert!(
|
||||
pxml.contains("<a:p><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>Before </a:t></a:r><a:r><a:rPr sz=\"1200\" i=\"1\"></a:rPr><a:t>Chevron </a:t></a:r><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>after.</a:t></a:r></a:p>"),
|
||||
"{pxml}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #4158: the runs keep their order along the line whatever order the blocks come in, and the
|
||||
/// paragraph keeps its place on the page, though the italic run stands taller than the first.
|
||||
#[test]
|
||||
fn a_sentence_keeps_its_reading_order() {
|
||||
let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)];
|
||||
blocks.extend(italic_sentence().into_iter().rev());
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The text of a block leaves out the spaces at its ends, and the box of a run often takes in
|
||||
/// its trailing space, so runs can touch. The source's own space still separates them; runs
|
||||
/// that touch with no space between them (a superscript) stay joined.
|
||||
#[test]
|
||||
fn runs_keep_the_spaces_the_source_had_between_them() {
|
||||
let blocks = vec![
|
||||
Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) },
|
||||
Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) },
|
||||
Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) },
|
||||
block("E = mc", 72.0, 660.0, 110.0, 672.0),
|
||||
Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) },
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
}
|
||||
/// Text turned another way (a label up the margin) is not a run of the line it touches.
|
||||
#[test]
|
||||
fn turned_text_is_not_a_run_of_the_line_beside_it() {
|
||||
let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let h = html(std::slice::from_ref(&p), "Turned");
|
||||
assert!(h.contains("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{h}");
|
||||
}
|
||||
/// A block that starts where the line above ends is the next line, not a run of it.
|
||||
#[test]
|
||||
fn a_block_on_the_next_line_starts_a_paragraph() {
|
||||
let blocks = vec![
|
||||
block("The first line ends here", 72.0, 700.0, 190.0, 712.0),
|
||||
Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) },
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml");
|
||||
assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}");
|
||||
}
|
||||
|
||||
/// A superscript beside a table cell stays in the table: table detection consumes it before
|
||||
/// paragraphs are formed, and a sentence after the table still keeps its italic run.
|
||||
#[test]
|
||||
fn table_cells_are_not_runs_of_a_paragraph() {
|
||||
let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
|
||||
for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() {
|
||||
let y = 680.0 - 20.0 * n as f64;
|
||||
blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
|
||||
blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
|
||||
blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
|
||||
}
|
||||
blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b }));
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:tbl>").count(), 1, "{xml}");
|
||||
for cell in ["st", "nd", "rd", "30,000"] {
|
||||
assert_eq!(xml.matches(&format!("preserve\">{cell}</w:t>")).count(), 1, "{cell}: {xml}");
|
||||
}
|
||||
// The cells are all in the table: the only flowing paragraph is the sentence.
|
||||
assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}");
|
||||
}
|
||||
|
||||
/// A bold lead-in followed by body text on its line is body text, not a heading; a heading
|
||||
/// with a smaller footnote marker after it stays a heading.
|
||||
#[test]
|
||||
fn a_paragraph_is_a_heading_only_when_its_text_is() {
|
||||
let blocks = vec![
|
||||
Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) },
|
||||
Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) },
|
||||
Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) },
|
||||
block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0),
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:pStyle w:val=\"Heading1\"/>").count(), 1, "{xml}");
|
||||
assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
|
||||
assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -1,11 +1,12 @@
|
||||
//! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page,
|
||||
//! largest text as slide title, remaining paragraphs as body text, images and tables between them.
|
||||
|
||||
use crate::{Block, Image, Page, Table, Zip, esc, hex};
|
||||
use crate::{Image, Page, Run, Table, Zip, esc, hex};
|
||||
|
||||
/// Items on a slide in reading order.
|
||||
enum SlideBodyItem<'a> {
|
||||
Para(&'a Block),
|
||||
/// A paragraph's runs (at least one).
|
||||
Para(Vec<Run<'a>>),
|
||||
Table(Table),
|
||||
Img(&'a Image),
|
||||
}
|
||||
@@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
.map(|(i, _)| i);
|
||||
|
||||
let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new();
|
||||
for (bi, b) in p.blocks.iter().enumerate() {
|
||||
if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b)));
|
||||
// The title is a shape of its own, so it joins no paragraph.
|
||||
let skip: Vec<bool> = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect();
|
||||
for runs in crate::paragraphs(&p.blocks, &skip) {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs)));
|
||||
}
|
||||
for t in tables {
|
||||
items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t)));
|
||||
@@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
SlideBodyItem::Para(_) => {
|
||||
let mut paras_xml = String::new();
|
||||
let mut est_h_pt = 0.0;
|
||||
while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) {
|
||||
let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color);
|
||||
paras_xml.push_str(&format!("<a:p>{run}</a:p>"));
|
||||
let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0);
|
||||
est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0;
|
||||
while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) {
|
||||
paras_xml.push_str("<a:p>");
|
||||
for r in runs {
|
||||
paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
||||
}
|
||||
paras_xml.push_str("</a:p>");
|
||||
let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add);
|
||||
let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max);
|
||||
let lines = (len as f64 / 60.0).ceil().max(1.0);
|
||||
est_h_pt += (size * 1.3) * lines + 6.0;
|
||||
i += 1;
|
||||
}
|
||||
let box_h = height_emu(est_h_pt);
|
||||
@@ -354,6 +360,7 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\"
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Block;
|
||||
use crate::tests::part;
|
||||
|
||||
fn sample_page() -> Page {
|
||||
@@ -369,6 +376,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.1, 0.2, 0.3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
Block {
|
||||
text: "Revenue grew 25% year over year across all regions.".into(),
|
||||
@@ -378,6 +386,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
Block {
|
||||
text: "Operational expenses remained flat.".into(),
|
||||
@@ -387,6 +396,7 @@ mod tests {
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
],
|
||||
images: vec![Image {
|
||||
@@ -510,6 +520,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let table_page = Page {
|
||||
width: 612.0,
|
||||
@@ -611,6 +622,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let mut blocks = vec![huge("Title", f64::MAX, 500.0)];
|
||||
for k in 0..40 {
|
||||
@@ -682,6 +694,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let p = Page {
|
||||
width: 720.0,
|
||||
@@ -696,6 +709,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
// Table where one cell has a very large number (e.g. 36 pt callout in the table)
|
||||
cell("Metric", 72.0, 400.0, 100.0, 12.0),
|
||||
|
||||
@@ -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
|
||||
@@ -58,7 +60,9 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
|
||||
one field redraws that field, not the form. Check boxes and radio buttons with no appearance for
|
||||
their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats
|
||||
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption.
|
||||
symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`,
|
||||
over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and
|
||||
their caption.
|
||||
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
|
||||
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
|
||||
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
|
||||
|
||||
+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)))
|
||||
}
|
||||
|
||||
@@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color:
|
||||
}
|
||||
|
||||
/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when
|
||||
/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and
|
||||
/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes
|
||||
/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of
|
||||
/// the file has no appearance for that state, or leaves its appearances to the viewer
|
||||
/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption
|
||||
/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may
|
||||
/// use too) as a ZapfDingbats character, as in the check box example of
|
||||
/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is
|
||||
/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most
|
||||
/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed.
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
+104
-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(())
|
||||
}
|
||||
|
||||
@@ -1465,8 +1553,9 @@ pub fn need_appearances(doc: &Document) -> bool {
|
||||
/// writer left the appearances to the viewer), and otherwise each widget without a normal
|
||||
/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons
|
||||
/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border
|
||||
/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not
|
||||
/// changed: saving writes the file as it was.
|
||||
/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own
|
||||
/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is
|
||||
/// not changed: saving writes the file as it was.
|
||||
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
|
||||
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
|
||||
}
|
||||
@@ -1484,19 +1573,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
|
||||
.and_then(|d| d.get(b"AP"))
|
||||
.and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n))));
|
||||
// A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`,
|
||||
// else a check box's value, else Off. An Off state may go without one (it shows
|
||||
// nothing); an on state needs one.
|
||||
// else the value when it names this widget's on state (any check box value when the
|
||||
// widget names none), else Off. An Off state may go without one (it shows nothing);
|
||||
// an on state needs one.
|
||||
let state = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => {
|
||||
let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off");
|
||||
let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v);
|
||||
let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine);
|
||||
Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// With /NeedAppearances a check box or radio button that describes its look in /MK is
|
||||
// drawn from it (Table 192), its own appearance or not; one without /MK keeps its own.
|
||||
let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some());
|
||||
let missing = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
|
||||
FieldKind::PushButton => normal.is_none(),
|
||||
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
|
||||
_ if all && described() => true,
|
||||
(None, _) => true,
|
||||
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
|
||||
_ => false,
|
||||
|
||||
@@ -4359,3 +4359,221 @@ fn comb_fields_show_their_cells() {
|
||||
let plain = saved_look(&doc, "d");
|
||||
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that
|
||||
/// sets `/NeedAppearances` (`need`) or not.
|
||||
fn mk_buttons(need: bool) -> Document {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>");
|
||||
let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>");
|
||||
document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
|
||||
format!(
|
||||
"<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>"
|
||||
),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6
|
||||
widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))),
|
||||
widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))),
|
||||
widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))),
|
||||
widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10
|
||||
widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))),
|
||||
widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))),
|
||||
widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))),
|
||||
widget(
|
||||
"odd",
|
||||
&format!(
|
||||
"/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}",
|
||||
states("Yes")
|
||||
),
|
||||
),
|
||||
"<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square
|
||||
"<< /Length 4 >>\nstream\nq\nQ\nendstream".into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it,
|
||||
/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour
|
||||
/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched.
|
||||
#[test]
|
||||
fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() {
|
||||
let doc = mk_buttons(true);
|
||||
let copy = with_field_appearances(&doc).expect("buttons to draw");
|
||||
let all = fields(©);
|
||||
for (name, state, symbol, colour) in [
|
||||
("check", "Yes", "(4) Tj", "0 g"),
|
||||
("cross", "Yes", "(8) Tj", "1 0 0 rg"),
|
||||
("diamond", "Yes", "(u) Tj", "0 g"),
|
||||
("square", "Yes", "(n) Tj", "0 g"),
|
||||
("star", "Yes", "(H) Tj", "0 g"),
|
||||
("radio", "B", "(l) Tj", "0 g"),
|
||||
] {
|
||||
let shown = state_ap(©, &field(&all, name).widgets[0], state);
|
||||
assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}");
|
||||
assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}");
|
||||
}
|
||||
let check = state_ap(©, &field(&all, "check").widgets[0], "Yes");
|
||||
assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}");
|
||||
assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background");
|
||||
assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border");
|
||||
let off = state_ap(©, &field(&all, "off").widgets[0], "Off");
|
||||
assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}");
|
||||
// Nonsense in /MK, /DA and /BS: the default check, no border, and no panic.
|
||||
let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes");
|
||||
assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}");
|
||||
let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned());
|
||||
assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look");
|
||||
assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched");
|
||||
}
|
||||
|
||||
/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown
|
||||
/// as they are, `/MK` or not: no copy is needed.
|
||||
#[test]
|
||||
fn without_need_appearances_buttons_keep_their_own_appearances() {
|
||||
assert!(with_field_appearances(&mk_buttons(false)).is_none());
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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,8 +93,8 @@ pub fn has_rtl(text: &str) -> bool {
|
||||
text.chars().any(is_rtl_script)
|
||||
}
|
||||
|
||||
/// The paragraph direction for translated interface text: explicitly right to left for Arabic
|
||||
/// and Kurdish, and `TextDirection::Auto` otherwise. A label whose first strong character is
|
||||
/// The paragraph direction for translated interface text: explicitly right to left for Arabic,
|
||||
/// Kurdish and Persian, and `TextDirection::Auto` otherwise. A label whose first strong character is
|
||||
/// Latin or a `{placeholder}` must not fall back to a left-to-right paragraph in an RTL locale.
|
||||
pub fn ui_direction() -> egui::epaint::text::TextDirection {
|
||||
if crate::i18n::current().rtl() { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::Auto }
|
||||
|
||||
@@ -3561,8 +3561,7 @@ 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(painter, &xf, i, info, &view.content, page_tex, pointer);
|
||||
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);
|
||||
@@ -4008,20 +4007,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
@@ -123,6 +123,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]>,
|
||||
@@ -135,7 +139,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) {
|
||||
@@ -150,6 +159,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);
|
||||
}
|
||||
@@ -157,39 +167,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());
|
||||
}
|
||||
@@ -203,21 +234,11 @@ 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 (to the pages' `pointer`) or
|
||||
/// applied position.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&self,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
cv: &ContentView,
|
||||
page_tex: Option<egui::TextureId>,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
|
||||
/// 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;
|
||||
}
|
||||
@@ -236,7 +257,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()),
|
||||
@@ -952,19 +973,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)));
|
||||
}
|
||||
@@ -1032,6 +1062,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]
|
||||
|
||||
@@ -2825,3 +2825,11 @@ 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} رد
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools أدوات الصفحات
|
||||
Insert pages from a file after the selection… إدراج صفحات من ملف بعد التحديد…
|
||||
Rotate the selected pages counterclockwise تدوير الصفحات المحددة عكس اتجاه عقارب الساعة
|
||||
Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة
|
||||
Extract the selected pages… استخراج الصفحات المحددة…
|
||||
Delete the selected pages حذف الصفحات المحددة
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2512,3 +2512,11 @@ animation effects Off Изключени
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Днес
|
||||
@plural {n} reply|{n} replies {n} отговор|{n} отговора
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Инструменти за страници
|
||||
Insert pages from a file after the selection… Вмъкване на страници от файл след избраното…
|
||||
Rotate the selected pages counterclockwise Завъртане на избраните страници обратно на часовниковата стрелка
|
||||
Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка
|
||||
Extract the selected pages… Извличане на избраните страници…
|
||||
Delete the selected pages Изтриване на избраните страници
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2810,3 +2810,11 @@ theme Appearance Aspecte
|
||||
Delete attachment Elimina l'adjunt
|
||||
Delete attachment {key} Elimina l'adjunt {key}
|
||||
@plural {n} reply|{n} replies {n} resposta|{n} respostes
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Eines de pàgina
|
||||
Insert pages from a file after the selection… Insereix pàgines d'un fitxer després de la selecció…
|
||||
Rotate the selected pages counterclockwise Gira les pàgines seleccionades en sentit antihorari
|
||||
Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari
|
||||
Extract the selected pages… Extreu les pàgines seleccionades…
|
||||
Delete the selected pages Suprimeix les pàgines seleccionades
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 3.
|
@@ -2741,3 +2741,11 @@ certificate Version وەشان
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ئەمڕۆ
|
||||
@plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools ئامرازەکانی پەڕە
|
||||
Insert pages from a file after the selection… پەڕەکان لە فایلێکەوە لە دوای هەڵبژاردەکە دابنێ…
|
||||
Rotate the selected pages counterclockwise پەڕە هەڵبژێردراوەکان پێچەوانەی میلی کاتژمێر بسوڕێنە
|
||||
Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە
|
||||
Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە…
|
||||
Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2856,3 +2856,11 @@ 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í
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Nástroje stránek
|
||||
Insert pages from a file after the selection… Vložit stránky ze souboru za výběr…
|
||||
Rotate the selected pages counterclockwise Otočit vybrané stránky proti směru hodinových ručiček
|
||||
Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček
|
||||
Extract the selected pages… Extrahovat vybrané stránky…
|
||||
Delete the selected pages Odstranit vybrané stránky
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2550,3 +2550,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Seitenwerkzeuge
|
||||
Insert pages from a file after the selection… Seiten aus einer Datei nach der Auswahl einfügen…
|
||||
Rotate the selected pages counterclockwise Ausgewählte Seiten gegen den Uhrzeigersinn drehen
|
||||
Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen
|
||||
Extract the selected pages… Ausgewählte Seiten extrahieren…
|
||||
Delete the selected pages Ausgewählte Seiten löschen
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2555,3 +2555,11 @@ animation effects Off Ανενεργά
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Σήμερα
|
||||
@plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Εργαλεία σελίδων
|
||||
Insert pages from a file after the selection… Εισαγωγή σελίδων από αρχείο μετά την επιλογή…
|
||||
Rotate the selected pages counterclockwise Περιστροφή των επιλεγμένων σελίδων αριστερόστροφα
|
||||
Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα
|
||||
Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων…
|
||||
Delete the selected pages Διαγραφή των επιλεγμένων σελίδων
|
||||
|
Can't render this file because it contains an unexpected character in line 482 and column 9.
|
@@ -2872,3 +2872,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Herramientas de página
|
||||
Insert pages from a file after the selection… Insertar páginas de un archivo después de la selección…
|
||||
Rotate the selected pages counterclockwise Girar las páginas seleccionadas en sentido antihorario
|
||||
Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario
|
||||
Extract the selected pages… Extraer las páginas seleccionadas…
|
||||
Delete the selected pages Eliminar las páginas seleccionadas
|
||||
|
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
@@ -2819,3 +2819,11 @@ theme Appearance Teema
|
||||
Delete attachment Poista liite
|
||||
Delete attachment {key} Poista liite {key}
|
||||
@plural {n} reply|{n} replies {n} vastaus|{n} vastausta
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Sivutyökalut
|
||||
Insert pages from a file after the selection… Lisää sivuja tiedostosta valinnan jälkeen…
|
||||
Rotate the selected pages counterclockwise Kierrä valittuja sivuja vastapäivään
|
||||
Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään
|
||||
Extract the selected pages… Pura valitut sivut…
|
||||
Delete the selected pages Poista valitut sivut
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 47.
|
@@ -2815,3 +2815,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Outils de page
|
||||
Insert pages from a file after the selection… Insérer des pages d'un fichier après la sélection…
|
||||
Rotate the selected pages counterclockwise Faire pivoter les pages sélectionnées dans le sens antihoraire
|
||||
Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire
|
||||
Extract the selected pages… Extraire les pages sélectionnées…
|
||||
Delete the selected pages Supprimer les pages sélectionnées
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2786,3 +2786,11 @@ theme Appearance स्वरूप
|
||||
Delete attachment अनुलग्नक हटाएँ
|
||||
Delete attachment {key} अनुलग्नक {key} हटाएँ
|
||||
@plural {n} reply|{n} replies {n} जवाब
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools पृष्ठ उपकरण
|
||||
Insert pages from a file after the selection… चयन के बाद किसी फ़ाइल से पृष्ठ डालें…
|
||||
Rotate the selected pages counterclockwise चयनित पृष्ठों को वामावर्त घुमाएँ
|
||||
Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ
|
||||
Extract the selected pages… चयनित पृष्ठ निकालें…
|
||||
Delete the selected pages चयनित पृष्ठ हटाएँ
|
||||
|
Can't render this file because it contains an unexpected character in line 864 and column 12.
|
@@ -2548,3 +2548,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Oldaleszközök
|
||||
Insert pages from a file after the selection… Oldalak beszúrása fájlból a kijelölés után…
|
||||
Rotate the selected pages counterclockwise A kijelölt oldalak forgatása az óramutató járásával ellentétesen
|
||||
Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően
|
||||
Extract the selected pages… A kijelölt oldalak kibontása…
|
||||
Delete the selected pages A kijelölt oldalak törlése
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2787,3 +2787,11 @@ theme Appearance Tampilan
|
||||
Delete attachment Hapus lampiran
|
||||
Delete attachment {key} Hapus lampiran {key}
|
||||
@plural {n} reply|{n} replies {n} balasan
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Alat halaman
|
||||
Insert pages from a file after the selection… Sisipkan halaman dari file setelah pilihan…
|
||||
Rotate the selected pages counterclockwise Putar halaman yang dipilih berlawanan arah jarum jam
|
||||
Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam
|
||||
Extract the selected pages… Ekstrak halaman yang dipilih…
|
||||
Delete the selected pages Hapus halaman yang dipilih
|
||||
|
Can't render this file because it contains an unexpected character in line 483 and column 12.
|
@@ -2781,3 +2781,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Strumenti pagina
|
||||
Insert pages from a file after the selection… Inserisci pagine da un file dopo la selezione…
|
||||
Rotate the selected pages counterclockwise Ruota le pagine selezionate in senso antiorario
|
||||
Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario
|
||||
Extract the selected pages… Estrai le pagine selezionate…
|
||||
Delete the selected pages Elimina le pagine selezionate
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2549,3 +2549,11 @@ animation effects Off オフ
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今日
|
||||
@plural {n} reply|{n} replies {n} 件の返信
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools ページツール
|
||||
Insert pages from a file after the selection… 選択範囲の後にファイルからページを挿入…
|
||||
Rotate the selected pages counterclockwise 選択したページを反時計回りに回転
|
||||
Rotate the selected pages clockwise 選択したページを時計回りに回転
|
||||
Extract the selected pages… 選択したページを抽出…
|
||||
Delete the selected pages 選択したページを削除
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2547,3 +2547,11 @@ animation effects Off Өшірулі
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Бүгін
|
||||
@plural {n} reply|{n} replies {n} жауап|{n} жауап
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Бет құралдары
|
||||
Insert pages from a file after the selection… Таңдаудан кейін файлдан беттер кірістіру…
|
||||
Rotate the selected pages counterclockwise Таңдалған беттерді сағат тіліне қарсы бұру
|
||||
Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру
|
||||
Extract the selected pages… Таңдалған беттерді шығарып алу…
|
||||
Delete the selected pages Таңдалған беттерді жою
|
||||
|
Can't render this file because it contains an unexpected character in line 869 and column 9.
|
@@ -2786,3 +2786,11 @@ theme Appearance 모양
|
||||
Delete attachment 첨부 파일 삭제
|
||||
Delete attachment {key} 첨부 파일 {key} 삭제
|
||||
@plural {n} reply|{n} replies 답글 {n}개
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools 페이지 도구
|
||||
Insert pages from a file after the selection… 선택 영역 뒤에 파일에서 페이지 삽입…
|
||||
Rotate the selected pages counterclockwise 선택한 페이지를 시계 반대 방향으로 회전
|
||||
Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전
|
||||
Extract the selected pages… 선택한 페이지 추출…
|
||||
Delete the selected pages 선택한 페이지 삭제
|
||||
|
Can't render this file because it contains an unexpected character in line 864 and column 12.
|
@@ -2457,3 +2457,11 @@ animation effects Off ປິດ
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ມື້ນີ້
|
||||
@plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools ເຄື່ອງມືໜ້າ
|
||||
Insert pages from a file after the selection… ແຊກໜ້າຈາກໄຟລ໌ຫຼັງການເລືອກ…
|
||||
Rotate the selected pages counterclockwise ໝູນໜ້າທີ່ເລືອກທວນເຂັມໂມງ
|
||||
Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ
|
||||
Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ…
|
||||
Delete the selected pages ລຶບໜ້າທີ່ເລືອກ
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 32.
|
@@ -2810,3 +2810,11 @@ 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ų
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Puslapių įrankiai
|
||||
Insert pages from a file after the selection… Įterpti puslapius iš failo po pasirinkimo…
|
||||
Rotate the selected pages counterclockwise Pasukti pasirinktus puslapius prieš laikrodžio rodyklę
|
||||
Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę
|
||||
Extract the selected pages… Išskleisti pasirinktus puslapius…
|
||||
Delete the selected pages Ištrinti pasirinktus puslapius
|
||||
|
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).
|
||||
@@ -362,6 +366,7 @@ impl Lang {
|
||||
"it" => "Italian",
|
||||
"ar" => "Arabic",
|
||||
"ckb" => "Central Kurdish",
|
||||
"fa" => "Persian",
|
||||
"pl" => "Polish",
|
||||
"sl" => "Slovenian",
|
||||
"el" => "Greek",
|
||||
@@ -370,6 +375,7 @@ impl Lang {
|
||||
"id" => "Indonesian",
|
||||
"vi" => "Vietnamese",
|
||||
"fi" => "Finnish",
|
||||
"nb" => "Norwegian Bokmål",
|
||||
"sq" => "Albanian",
|
||||
"th" => "Thai",
|
||||
"ca" => "Catalan",
|
||||
@@ -778,6 +784,7 @@ mod tests {
|
||||
assert_eq!(Lang::EN.picker_name(|_| false), "English");
|
||||
for language in Lang::all() {
|
||||
assert!(!language.english_name().is_empty(), "{}", language.code());
|
||||
assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,7 +1753,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}");
|
||||
@@ -1796,6 +1803,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 in logical order; epaint handles visual ordering.
|
||||
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).
|
||||
|
||||
@@ -2725,3 +2725,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Sideverktøy
|
||||
Insert pages from a file after the selection… Sett inn sider fra en fil etter utvalget…
|
||||
Rotate the selected pages counterclockwise Roter de valgte sidene mot klokken
|
||||
Rotate the selected pages clockwise Roter de valgte sidene med klokken
|
||||
Extract the selected pages… Trekk ut de valgte sidene…
|
||||
Delete the selected pages Slett de valgte sidene
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2645,3 +2645,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Narzędzia stron
|
||||
Insert pages from a file after the selection… Wstaw strony z pliku po zaznaczeniu…
|
||||
Rotate the selected pages counterclockwise Obróć zaznaczone strony w lewo
|
||||
Rotate the selected pages clockwise Obróć zaznaczone strony w prawo
|
||||
Extract the selected pages… Wyodrębnij zaznaczone strony…
|
||||
Delete the selected pages Usuń zaznaczone strony
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2834,3 +2834,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Ferramentas de página
|
||||
Insert pages from a file after the selection… Inserir páginas de um arquivo após a seleção…
|
||||
Rotate the selected pages counterclockwise Girar as páginas selecionadas no sentido anti-horário
|
||||
Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário
|
||||
Extract the selected pages… Extrair as páginas selecionadas…
|
||||
Delete the selected pages Excluir as páginas selecionadas
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2810,3 +2810,11 @@ theme Appearance Aspect
|
||||
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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Instrumente pentru pagini
|
||||
Insert pages from a file after the selection… Inserează pagini dintr-un fișier după selecție…
|
||||
Rotate the selected pages counterclockwise Rotește paginile selectate în sens antiorar
|
||||
Rotate the selected pages clockwise Rotește paginile selectate în sens orar
|
||||
Extract the selected pages… Extrage paginile selectate…
|
||||
Delete the selected pages Șterge paginile selectate
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 110.
|
@@ -2462,3 +2462,11 @@ animation effects Off Выкл
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сегодня
|
||||
@plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Инструменты страниц
|
||||
Insert pages from a file after the selection… Вставить страницы из файла после выделения…
|
||||
Rotate the selected pages counterclockwise Повернуть выбранные страницы против часовой стрелки
|
||||
Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке
|
||||
Extract the selected pages… Извлечь выбранные страницы…
|
||||
Delete the selected pages Удалить выбранные страницы
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2554,3 +2554,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Orodja za strani
|
||||
Insert pages from a file after the selection… Vstavi strani iz datoteke za izborom…
|
||||
Rotate the selected pages counterclockwise Zavrti izbrane strani v nasprotni smeri urnega kazalca
|
||||
Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca
|
||||
Extract the selected pages… Izvleci izbrane strani…
|
||||
Delete the selected pages Izbriši izbrane strani
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2536,3 +2536,11 @@ animation effects Off Joaktiv
|
||||
Delete attachment Fshi bashkëngjitjen
|
||||
Delete attachment {key} Fshi bashkëngjitjen {key}
|
||||
@plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Mjetet e faqes
|
||||
Insert pages from a file after the selection… Fut faqe nga një skedar pas përzgjedhjes…
|
||||
Rotate the selected pages counterclockwise Rrotullo faqet e përzgjedhura në drejtim të kundërt me orën
|
||||
Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës
|
||||
Extract the selected pages… Nxirr faqet e përzgjedhura…
|
||||
Delete the selected pages Fshi faqet e përzgjedhura
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2458,3 +2458,11 @@ animation effects Off ఆఫ్
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ఈరోజు
|
||||
@plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools పేజీ సాధనాలు
|
||||
Insert pages from a file after the selection… ఎంపిక తర్వాత ఫైల్ నుండి పేజీలను చొప్పించు…
|
||||
Rotate the selected pages counterclockwise ఎంచుకున్న పేజీలను అపసవ్య దిశలో తిప్పు
|
||||
Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు
|
||||
Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు…
|
||||
Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2712,3 +2712,11 @@ 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} รายการ
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools เครื่องมือหน้า
|
||||
Insert pages from a file after the selection… แทรกหน้าจากไฟล์หลังส่วนที่เลือก…
|
||||
Rotate the selected pages counterclockwise หมุนหน้าที่เลือกทวนเข็มนาฬิกา
|
||||
Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา
|
||||
Extract the selected pages… แยกหน้าที่เลือกออก…
|
||||
Delete the selected pages ลบหน้าที่เลือก
|
||||
|
Can't render this file because it contains an unexpected character in line 875 and column 12.
|
@@ -2545,3 +2545,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Sayfa araçları
|
||||
Insert pages from a file after the selection… Seçimin ardından dosyadan sayfa ekle…
|
||||
Rotate the selected pages counterclockwise Seçili sayfaları saat yönünün tersine döndür
|
||||
Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür
|
||||
Extract the selected pages… Seçili sayfaları ayıkla…
|
||||
Delete the selected pages Seçili sayfaları sil
|
||||
|
Can't render this file because it contains an unexpected character in line 868 and column 9.
|
@@ -2571,3 +2571,11 @@ animation effects Off Вимкнено
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сьогодні
|
||||
@plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Інструменти сторінок
|
||||
Insert pages from a file after the selection… Вставити сторінки з файлу після виділення…
|
||||
Rotate the selected pages counterclockwise Повернути вибрані сторінки проти годинникової стрілки
|
||||
Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою
|
||||
Extract the selected pages… Витягнути вибрані сторінки…
|
||||
Delete the selected pages Видалити вибрані сторінки
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2816,3 +2816,11 @@ 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
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Công cụ trang
|
||||
Insert pages from a file after the selection… Chèn trang từ tệp sau vùng chọn…
|
||||
Rotate the selected pages counterclockwise Xoay các trang đã chọn ngược chiều kim đồng hồ
|
||||
Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ
|
||||
Extract the selected pages… Trích xuất các trang đã chọn…
|
||||
Delete the selected pages Xóa các trang đã chọn
|
||||
|
Can't render this file because it contains an unexpected character in line 493 and column 12.
|
@@ -2513,3 +2513,11 @@ animation effects Off 关
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@plural {n} reply|{n} replies {n} 条回复
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools 页面工具
|
||||
Insert pages from a file after the selection… 在所选内容后插入文件中的页面…
|
||||
Rotate the selected pages counterclockwise 逆时针旋转所选页面
|
||||
Rotate the selected pages clockwise 顺时针旋转所选页面
|
||||
Extract the selected pages… 提取所选页面…
|
||||
Delete the selected pages 删除所选页面
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2499,3 +2499,11 @@ animation effects Off 關
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@plural {n} reply|{n} replies {n} 則回覆
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools 頁面工具
|
||||
Insert pages from a file after the selection… 在所選內容後插入檔案中的頁面…
|
||||
Rotate the selected pages counterclockwise 逆時針旋轉所選頁面
|
||||
Rotate the selected pages clockwise 順時針旋轉所選頁面
|
||||
Extract the selected pages… 擷取所選頁面…
|
||||
Delete the selected pages 刪除所選頁面
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -3734,6 +3734,9 @@ impl eframe::App for PdfCraftApp {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.window_title = title;
|
||||
}
|
||||
// The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on
|
||||
// Windows) can tell it apart (#4614). egui builds the root node afresh every pass.
|
||||
ctx.accesskit_node_builder(egui::accesskit_root_id(), |root| root.set_label(self.window_title.as_str()));
|
||||
if self.full_screen && self.active.is_some() {
|
||||
// Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc
|
||||
// or ⌘L to leave).
|
||||
|
||||
@@ -117,8 +117,19 @@ impl Default for MarksDraft {
|
||||
|
||||
const PAGE_FORMATS: [&str; 5] = ["<<1>>", "<<1 of n>>", "<<1/n>>", "<<Page 1>>", "<<Page 1 of n>>"];
|
||||
// New formats go last: `date_format` is an index into this list.
|
||||
const DATE_FORMATS: [&str; 8] =
|
||||
["<<m/d/yyyy>>", "<<mm/dd/yyyy>>", "<<d/m/yyyy>>", "<<yyyy-mm-dd>>", "<<m/d/yy>>", "<<mmmm d, yyyy>>", "<<yyyy/mm/dd>>", "<<yyyy/m/d>>"];
|
||||
const DATE_FORMATS: [&str; 11] = [
|
||||
"<<m/d/yyyy>>",
|
||||
"<<mm/dd/yyyy>>",
|
||||
"<<d/m/yyyy>>",
|
||||
"<<yyyy-mm-dd>>",
|
||||
"<<m/d/yy>>",
|
||||
"<<mmmm d, yyyy>>",
|
||||
"<<yyyy/mm/dd>>",
|
||||
"<<yyyy/m/d>>",
|
||||
"<<dd.mm.yyyy>>",
|
||||
"<<d.m.yyyy>>",
|
||||
"<<dd.mm.yy>>",
|
||||
];
|
||||
const BOX_NAMES: [&str; 6] =
|
||||
["Left Header Text", "Center Header Text", "Right Header Text", "Left Footer Text", "Center Footer Text", "Right Footer Text"];
|
||||
|
||||
|
||||
+116
-34
@@ -733,6 +733,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
if panel == RightPanel::Pages
|
||||
&& let Some(c) = page_tools(ui, !modal && view.panel_drag.is_none() && bm_editable)
|
||||
{
|
||||
panel_command = Some(c);
|
||||
}
|
||||
// Right to left: close, "…", filter, search (Acrobat's order left to right).
|
||||
if panel == RightPanel::Comments {
|
||||
panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden());
|
||||
@@ -916,42 +921,87 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
empty(ui, &t, "paperclip", "This document has no attachments.");
|
||||
}
|
||||
for (ai, a) in info.attachments.iter().enumerate() {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("paperclip", 16.0, t.icon));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width() - 70.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate());
|
||||
let mut meta = a.size.map(human_size).unwrap_or_default();
|
||||
if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source {
|
||||
let on_page = crate::i18n::fmt(
|
||||
tl!("on page {label}"),
|
||||
&[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))],
|
||||
let is_pdf = a.name.to_lowercase().ends_with(".pdf");
|
||||
// Only an attachment of the document itself can be deleted here.
|
||||
let deletable = match &a.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key } if attachable => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
// The whole row answers a double-click and a right-click (#3466), below
|
||||
// its buttons, which still take their own clicks.
|
||||
let mut buttons: Vec<egui::Response> = Vec::new();
|
||||
let row = ui.scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("paperclip", 16.0, t.icon));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width() - 70.0);
|
||||
// Not selectable, so a click on the name or the details reaches the row.
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate().selectable(false),
|
||||
);
|
||||
meta = format!("{meta} · {on_page}");
|
||||
}
|
||||
if let Some(d) = &a.description {
|
||||
meta = format!("{meta} · {d}");
|
||||
}
|
||||
ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate());
|
||||
let mut meta = a.size.map(human_size).unwrap_or_default();
|
||||
if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source {
|
||||
let on_page = crate::i18n::fmt(
|
||||
tl!("on page {label}"),
|
||||
&[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))],
|
||||
);
|
||||
meta = format!("{meta} · {on_page}");
|
||||
}
|
||||
if let Some(d) = &a.description {
|
||||
meta = format!("{meta} · {d}");
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate().selectable(false),
|
||||
);
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let save = icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…"));
|
||||
if save.clicked() {
|
||||
attachment_action = Some((ai, false));
|
||||
}
|
||||
buttons.push(save);
|
||||
if is_pdf {
|
||||
let open = icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab"));
|
||||
if open.clicked() {
|
||||
attachment_action = Some((ai, true));
|
||||
}
|
||||
buttons.push(open);
|
||||
}
|
||||
if let Some(key) = deletable {
|
||||
let delete = icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment"));
|
||||
if delete.clicked() {
|
||||
panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() });
|
||||
}
|
||||
buttons.push(delete);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")).clicked() {
|
||||
attachment_action = Some((ai, false));
|
||||
}
|
||||
if a.name.to_lowercase().ends_with(".pdf")
|
||||
&& icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")).clicked()
|
||||
{
|
||||
attachment_action = Some((ai, true));
|
||||
}
|
||||
if let pdfcraft_render::AttachmentSource::Document { key } = &a.source
|
||||
&& attachable
|
||||
&& icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")).clicked()
|
||||
{
|
||||
panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() });
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
// A double-click opens a PDF in a new tab, as Acrobat opens an attachment;
|
||||
// any other file is saved, since PdfCraft opens only PDFs.
|
||||
if row.response.double_clicked() {
|
||||
attachment_action = Some((ai, is_pdf));
|
||||
}
|
||||
// A right-click on one of the row's buttons is a right-click on the row:
|
||||
// the union keeps the row's id, so it is one menu either way.
|
||||
let menu = buttons.into_iter().fold(row.response.clone(), |row, button| row | button);
|
||||
menu.context_menu(|ui| {
|
||||
if is_pdf && ui.button(tl!("Open in a new tab")).clicked() {
|
||||
attachment_action = Some((ai, true));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Save attachment…")).clicked() {
|
||||
attachment_action = Some((ai, false));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(key) = deletable
|
||||
&& ui.button(tl!("Delete attachment")).clicked()
|
||||
{
|
||||
panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1294,6 +1344,38 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
}
|
||||
}
|
||||
|
||||
/// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel,
|
||||
/// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its
|
||||
/// command on the selected pages (the current page when none is), so it does what Pages ▸ …
|
||||
/// does; the items say so, apart from the thumbnails' own menu. `enabled` is false while the
|
||||
/// document may not be assembled or a dialog is open. Returns the command a click asks for.
|
||||
fn page_tools(ui: &mut egui::Ui, enabled: bool) -> Option<&'static str> {
|
||||
let tools: [(&'static str, &str); 6] = [
|
||||
("page.insert_blank", tl!("Insert a blank page after the selection")),
|
||||
("page.insert", tl!("Insert pages from a file after the selection…")),
|
||||
("page.rotate_ccw", tl!("Rotate the selected pages counterclockwise")),
|
||||
("page.rotate", tl!("Rotate the selected pages clockwise")),
|
||||
("page.extract", tl!("Extract the selected pages…")),
|
||||
("page.delete", tl!("Delete the selected pages")),
|
||||
];
|
||||
let mut picked = None;
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Page tools"));
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(260.0);
|
||||
for (id, label) in tools {
|
||||
let Some(spec) = pdfcraft_engine::commands::command(id) else { continue };
|
||||
let item = egui::Button::image_and_text(icons::image(spec.icon, 16.0, ui.visuals().text_color()), label);
|
||||
if ui.add(item).clicked() {
|
||||
picked = Some(id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
/// Size controls stay above the scroll area, so they remain reachable on long documents.
|
||||
fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) {
|
||||
let before = view.thumbnail_zoom();
|
||||
|
||||
@@ -472,7 +472,7 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
}
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
// Arabic and Kurdish mirror stock chrome (menus, combo boxes, scroll bars, headers).
|
||||
// RTL languages mirror stock chrome (menus, combo boxes, scroll bars, headers).
|
||||
// Labels still get their paragraph direction explicitly from `bidi::ui_direction`.
|
||||
s.right_to_left = rtl;
|
||||
s.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
|
||||
@@ -249,20 +249,41 @@ pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) {
|
||||
|
||||
/// After `desktop_grab`, add the primary button's release to the next input, unless it brings one.
|
||||
/// The release goes before the input's first press (the next resize or move, or a click), which
|
||||
/// egui would otherwise end straight away; a release after that press is the press's own.
|
||||
/// egui would otherwise end straight away; a release after that press is the press's own. It also
|
||||
/// goes before the pointer leaving, as a button event would bring the gone pointer back; a release
|
||||
/// the input brings after the pointer left is the grab's own, and moves there instead.
|
||||
pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() {
|
||||
return;
|
||||
}
|
||||
let first = raw.events.iter().enumerate().find_map(|(n, e)| match e {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some((n, *pressed)),
|
||||
_ => None,
|
||||
});
|
||||
let at = match first {
|
||||
Some((_, false)) => return,
|
||||
Some((n, true)) => n,
|
||||
None => raw.events.len(),
|
||||
};
|
||||
let mut at = raw.events.len();
|
||||
let mut gone = None;
|
||||
let mut brought = None;
|
||||
for (n, e) in raw.events.iter().enumerate() {
|
||||
match e {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => {
|
||||
brought = Some(n);
|
||||
break;
|
||||
}
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } => {
|
||||
at = n;
|
||||
break;
|
||||
}
|
||||
egui::Event::PointerGone => {
|
||||
gone.get_or_insert(n);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(n) = brought {
|
||||
// `gone` was found before `n`, so both are in range.
|
||||
if let Some(g) = gone {
|
||||
let release = raw.events.remove(n);
|
||||
raw.events.insert(g, release);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let at = gone.unwrap_or(at);
|
||||
let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers));
|
||||
raw.events.insert(at, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
}
|
||||
@@ -390,4 +411,52 @@ mod tests {
|
||||
frame(vec![], Some(egui::ViewportCommand::StartDrag));
|
||||
assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_release_goes_before_the_pointer_leaving() {
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |events: Vec<egui::Event>, grab: bool| {
|
||||
let mut raw = egui::RawInput { events, ..Default::default() };
|
||||
super::release_after_desktop_grab(&ctx, &mut raw);
|
||||
let order = raw.events.clone();
|
||||
ctx.run_ui(raw, |ui| {
|
||||
if grab {
|
||||
super::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
order
|
||||
};
|
||||
let release = |e: &egui::Event| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. });
|
||||
let press = egui::Event::PointerButton {
|
||||
pos: Pos2::new(5.0, 5.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
};
|
||||
// On Wayland the input after a move brings only that the pointer left.
|
||||
frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), press], true);
|
||||
let order = frame(vec![egui::Event::PointerGone], false);
|
||||
assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "{order:?}");
|
||||
assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone");
|
||||
assert!(!ctx.input(|i| i.pointer.primary_down()));
|
||||
|
||||
// A release after the pointer left is the one the desktop kept, not to be doubled.
|
||||
frame(vec![], true);
|
||||
let order = frame(
|
||||
vec![
|
||||
egui::Event::PointerGone,
|
||||
egui::Event::PointerButton {
|
||||
pos: Pos2::new(5.0, 5.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "moved, not doubled: {order:?}");
|
||||
assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! An attachment's row in the Attachments panel answers a double-click and a right-click, as
|
||||
//! in Acrobat (#3466), while its buttons keep taking their own clicks.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
/// A one-page PDF with no embedded files, to attach to.
|
||||
const INNER: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A one-page PDF with two embedded files: `inner.pdf` (a PDF) and `notes.txt`.
|
||||
fn attachments_pdf() -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(inner.pdf) 5 0 R (notes.txt) 7 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 200 200] /Resources << >> >>".to_vec(),
|
||||
[format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(),
|
||||
b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(),
|
||||
b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(),
|
||||
b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a
|
||||
/// second so two clicks a frame apart make a double-click.
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap();
|
||||
app.set_option("panel", "attachments").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's saved file, removed afterwards.
|
||||
struct Dir(std::path::PathBuf);
|
||||
|
||||
impl Dir {
|
||||
fn new(test: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-attachment-row-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pointer clicks a frame apart on `label` (an accessibility click is never a double-click).
|
||||
fn double_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
|
||||
let at = row_name(h, label);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for _ in 0..2 {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
|
||||
let at = row_name(h, label);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_pdf_attachment_opens_it_in_a_new_tab() {
|
||||
let mut h = harness();
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
double_click(&mut h, "inner.pdf");
|
||||
assert_eq!(h.state().views.len(), 2, "the attached PDF opens in a tab of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_another_attachment_saves_it() {
|
||||
let dir = Dir::new("double");
|
||||
let target = dir.0.join("saved notes.txt");
|
||||
let mut h = harness();
|
||||
h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]);
|
||||
double_click(&mut h, "notes.txt");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "PdfCraft opens only PDFs, so it saves the rest");
|
||||
assert_eq!(h.state().views.len(), 1, "nothing opens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_click_menu_opens_saves_and_deletes() {
|
||||
let dir = Dir::new("menu");
|
||||
let target = dir.0.join("from menu.pdf");
|
||||
let mut h = harness();
|
||||
let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count();
|
||||
// The rows have Save and Delete buttons each, and the PDF's row an Open button.
|
||||
assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (2, 1, 2));
|
||||
// A text file's menu has Save and Delete, no Open.
|
||||
right_click(&mut h, "notes.txt");
|
||||
assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (3, 1, 3));
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
// Scope actions to the menu: egui nests it under its row in the accessibility tree.
|
||||
// Save writes the PDF, not the text file whose row comes last.
|
||||
right_click(&mut h, "inner.pdf");
|
||||
assert_eq!(count(&h, "Open in a new tab"), 2, "the PDF's menu offers Open");
|
||||
h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]);
|
||||
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Save attachment…").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(std::fs::read(&target).unwrap(), INNER, "Save from inner.pdf's menu");
|
||||
right_click(&mut h, "inner.pdf");
|
||||
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Open in a new tab").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views.len(), 2, "Open from the menu");
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(3);
|
||||
// Delete from inner.pdf's menu leaves notes.txt, whose row's button would delete itself.
|
||||
right_click(&mut h, "inner.pdf");
|
||||
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Delete attachment").click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.attachments.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["notes.txt"], "Delete from the menu");
|
||||
assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_buttons_still_take_their_own_clicks() {
|
||||
let dir = Dir::new("buttons");
|
||||
let target = dir.0.join("from button.txt");
|
||||
let mut h = harness();
|
||||
h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]);
|
||||
// The rows list inner.pdf first, then notes.txt: the second Save button is notes.txt's.
|
||||
h.get_all_by_label("Save attachment…").nth(1).unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "the Save button under the row's click area");
|
||||
h.get_by_label("Open in a new tab").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views.len(), 2, "the Open button");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4513: a right-click on one of a row's buttons opens that row's menu too, as a
|
||||
/// right-click anywhere on the row does.
|
||||
#[test]
|
||||
fn a_right_click_on_a_row_button_opens_the_row_menu() {
|
||||
let mut h = harness();
|
||||
let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count();
|
||||
// inner.pdf's row comes first: its Save button is the first one.
|
||||
let at = h.get_all_by_label("Save attachment…").next().expect("inner.pdf's Save button").rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert_eq!(count(&h, "Open in a new tab"), 2, "inner.pdf's menu, with Open, is open");
|
||||
assert_eq!(count(&h, "Save attachment…"), 3);
|
||||
}
|
||||
|
||||
/// Where the attachment named `label` shows in the panel: the lowest text with that name, below
|
||||
/// a tab of the same name once the attachment is open.
|
||||
fn row_name(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 {
|
||||
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).expect("the attachment is listed").center()
|
||||
}
|
||||
@@ -609,6 +609,39 @@ fn dragged_text_follows_the_pointer_before_release() {
|
||||
assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it");
|
||||
}
|
||||
|
||||
/// #4464: what a dragged text box covered in its first place stays there; only the text follows
|
||||
/// the pointer. The page has a red square under the text's first place and nothing elsewhere.
|
||||
#[test]
|
||||
fn dragged_text_leaves_what_it_covered_behind() {
|
||||
let _gpu = gpu();
|
||||
let content = "1 0 0 rg 20 60 240 70 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let mut h = harness_bytes(pdf.into_bytes());
|
||||
let (rect, from, to, end) = drag_added_text(&mut h);
|
||||
assert!(rect[0] > 20.0 && rect[2] < 260.0 && rect[1] > 60.0 && rect[3] < 130.0, "the text starts on the red square: {rect:?}");
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/added-text-drag-over-red.png")).unwrap();
|
||||
}
|
||||
let pixels = h.render().unwrap();
|
||||
let red = |r: egui::Rect| {
|
||||
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
|
||||
(y0..y1.min(pixels.height()))
|
||||
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
|
||||
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 200 && g < 80 && b < 80))
|
||||
.count()
|
||||
};
|
||||
let (old, new) = ink_at(&mut h, from, to);
|
||||
assert!(new > 20 && old == 0, "the text follows the pointer ({old} old, {new} new)");
|
||||
assert!(red(from) > 100, "the red square stays where the text was ({} red pixels)", red(from));
|
||||
assert_eq!(red(to), 0, "and doesn't come along with the text");
|
||||
h.drop_at(end);
|
||||
h.run_steps(3);
|
||||
assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it");
|
||||
}
|
||||
|
||||
/// After release the text stays in its new place while the page is re-rendered with the move,
|
||||
/// instead of jumping back to where it was for those frames.
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@ mod accessibility;
|
||||
mod actions;
|
||||
mod animate;
|
||||
mod attach_picker_target;
|
||||
mod attachment_row;
|
||||
mod bookmark_position;
|
||||
mod bookmarks;
|
||||
mod bulk_fields;
|
||||
|
||||
@@ -1474,6 +1474,52 @@ fn required_radio_buttons_get_a_round_red_border() {
|
||||
assert!(!stroke(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7));
|
||||
}
|
||||
|
||||
/// Two required check boxes: the first laid out for a round XFA check button (`/PCXfaRound`), the
|
||||
/// second an ordinary square one.
|
||||
const ROUND_CHECKS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (round) /V /Off /AS /Off /Rect [50 300 90 340] /PCXfaRound true /P 3 0 R >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (square) /V /Off /AS /Off /Rect [150 300 190 340] /P 3 0 R >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// With fields highlighted, a round XFA check box gets a round border, as a radio button does,
|
||||
/// while an ordinary check box keeps a square one.
|
||||
#[test]
|
||||
fn round_check_boxes_get_a_round_highlight() {
|
||||
let _gpu = gpu();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("checks.pdf", None, ROUND_CHECKS.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
app.set_option("fields", "on").unwrap();
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let img = h.render().expect("renders");
|
||||
let at = |x: f32, y: f32| {
|
||||
let p = egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0));
|
||||
*img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32)
|
||||
};
|
||||
// The red of the required border, or its antialiased edge over the field tint.
|
||||
let stroke = |p: image::Rgba<u8>| p[0] > 180 && p[2] < 140 && i16::from(p[0]) - i16::from(p[2]) > 80;
|
||||
assert!([0.05_f32, 0.2].into_iter().any(|dx| stroke(at(50.0 + dx, 320.0))), "the round box's left edge is red: {:?}", at(50.2, 320.0));
|
||||
// Near the top of the left edge: outside the circle, on a square's border.
|
||||
assert!(!stroke(at(50.25, 339.0)), "the round box's corner, outside the circle, is not: {:?}", at(50.25, 339.0));
|
||||
assert!(
|
||||
[0.1_f32, 0.25, 0.4].into_iter().any(|dx| stroke(at(150.0 + dx, 339.0))),
|
||||
"the square box's is: {:?} {:?}",
|
||||
at(150.1, 339.0),
|
||||
at(150.25, 339.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Acrobat's Highlight existing fields is a preference, not a per-document choice: turned off
|
||||
/// once, the next document (and the next session) opens with fields plain. New settings start
|
||||
/// with it on.
|
||||
@@ -2117,3 +2163,101 @@ fn full_screen_clicks_turn_pages_whatever_tool_was_on() {
|
||||
assert!(!h.state().full_screen);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back");
|
||||
}
|
||||
|
||||
/// #4415: the Pages panel's Page tools menu. Each tool acts on the selected pages (the current
|
||||
/// page when none is): rotate, delete and insert a blank page here, as Pages ▸ … does.
|
||||
#[test]
|
||||
fn the_pages_panel_tools_act_on_the_selected_pages() {
|
||||
let mut h = harness(&[("panel", "pages")]);
|
||||
let id = h.state().views[0].id;
|
||||
let rotations = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>();
|
||||
// The panel's Page tools menu, on the right-hand side of the window.
|
||||
let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| {
|
||||
h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(3);
|
||||
};
|
||||
h.state_mut().views[0].select_pages(&[1, 2]);
|
||||
h.run_steps(2);
|
||||
tool(&mut h, "Rotate the selected pages clockwise");
|
||||
assert_eq!(rotations(&h), [0, 90, 90], "pages 2 and 3, the selection");
|
||||
tool(&mut h, "Rotate the selected pages counterclockwise");
|
||||
assert_eq!(rotations(&h), [0, 0, 0]);
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
h.run_steps(2);
|
||||
tool(&mut h, "Delete the selected pages");
|
||||
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "page 1 deleted");
|
||||
tool(&mut h, "Insert a blank page after the selection");
|
||||
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "each is one undo step");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4583: the Page tools menu's file items work from the menu too. Insert pages
|
||||
/// from a file puts the picked file's pages after the selection; Extract the selected pages
|
||||
/// opens Extract for the selection and writes it.
|
||||
#[test]
|
||||
fn the_pages_panel_tools_insert_from_a_file_and_extract() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-page-tools-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let more = dir.join("more.pdf");
|
||||
std::fs::write(&more, PAGES).unwrap();
|
||||
let mut h = harness(&[("panel", "pages")]);
|
||||
let id = h.state().views[0].id;
|
||||
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.len();
|
||||
let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| {
|
||||
h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(3);
|
||||
};
|
||||
assert_eq!(count(&h), 3);
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
h.state_mut().pick_override = Some(vec![more.to_string_lossy().into_owned()]);
|
||||
tool(&mut h, "Insert pages from a file after the selection…");
|
||||
h.run_steps(3);
|
||||
assert_eq!(count(&h), 6, "the picked file's three pages are inserted");
|
||||
|
||||
let out = dir.join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned());
|
||||
h.state_mut().views[0].select_pages(&[1, 2]);
|
||||
tool(&mut h, "Extract the selected pages…");
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Extract), "Extract opens for the selection");
|
||||
h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() };
|
||||
h.get_all_by_label("Extract").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 2, "the two selected pages, each a file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The Pages panel's Page tools are greyed out on a document whose security doesn't allow
|
||||
/// page changes: the menu doesn't open, and nothing changes.
|
||||
#[test]
|
||||
fn the_pages_panel_tools_are_off_without_page_editing_permission() {
|
||||
use egui_kittest::kittest::NodeT;
|
||||
let mut h = harness(&[("panel", "pages")]);
|
||||
{
|
||||
let app = h.state_mut();
|
||||
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
|
||||
permissions_password: Some("owner".into()),
|
||||
changes: pdfcraft_engine::Changes::None,
|
||||
..Default::default()
|
||||
}));
|
||||
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
|
||||
app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
}
|
||||
h.run_steps(6);
|
||||
let i = h.state().active.unwrap();
|
||||
let id = h.state().views[i].id;
|
||||
assert!(!h.state().session.get(id).unwrap().allows_assembly());
|
||||
let tools = h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools");
|
||||
assert!(tools.accesskit_node().is_disabled(), "Page tools is greyed out");
|
||||
tools.click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Delete the selected pages").is_none(), "its menu doesn't open");
|
||||
assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3);
|
||||
}
|
||||
@@ -2245,3 +2245,26 @@ fn go_to_page_brings_the_page_into_view_horizontally() {
|
||||
let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect());
|
||||
assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}");
|
||||
}
|
||||
|
||||
/// #4614: the accessible window is named like the native one, and follows it when the active
|
||||
/// document changes, so a screen reader can find the window by its title.
|
||||
#[test]
|
||||
fn the_accessible_window_has_the_window_title_as_its_name() {
|
||||
use egui_kittest::kittest::NodeT as _;
|
||||
let mut h = harness();
|
||||
h.run_steps(2);
|
||||
let name = |h: &Harness<'static, PdfCraftApp>| h.root().accesskit_node().label().map(|l| l.to_string());
|
||||
assert_eq!(name(&h).as_deref(), Some("b.pdf — PdfCraft"));
|
||||
{
|
||||
use pdfcraft_engine::Edit;
|
||||
let s = h.state_mut();
|
||||
let id = s.views[s.active.unwrap()].id;
|
||||
s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap();
|
||||
let mut v = s.session.get(id).unwrap().initial_view();
|
||||
v.display_title = true;
|
||||
s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap();
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(name(&h).as_deref(), Some("Quarterly report — PdfCraft"));
|
||||
assert_eq!(name(&h), Some(h.state().window_title.clone()));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
Label = "Pages"
|
||||
Label = "2"
|
||||
Button "Close" [off]
|
||||
Button "Page tools" [off]
|
||||
Label = "Thumbnail size"
|
||||
Label = "100%"
|
||||
Button "Smaller thumbnails" [off]
|
||||
|
||||
@@ -38,8 +38,11 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
|
||||
format and keystroke actions from the picture clause), check boxes and radio groups (from
|
||||
`exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their
|
||||
scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps
|
||||
its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
|
||||
scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as
|
||||
fields under one for each step above it) because other viewers lay a dynamic form out again
|
||||
and find a signature by that name. A box whose path, or a part of it, the file already gives
|
||||
another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to
|
||||
generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
|
||||
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
|
||||
|
||||
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
|
||||
@@ -82,6 +85,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi
|
||||
| `/PCSom` | generated fields | the field's SOM path, so values go back to the data |
|
||||
| `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again |
|
||||
| `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written |
|
||||
| `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds |
|
||||
| `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` |
|
||||
|
||||
## API sketch
|
||||
|
||||
@@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec<u8> {
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
/// A static XFA form of `n` text fields `form1[0].page1[0].f<i>[0]` on one page, whose values
|
||||
/// (`v<i>`) are only in its datasets, as when another viewer filled it in. The first field is
|
||||
/// read-only.
|
||||
pub fn static_shell_many(n: usize) -> Vec<u8> {
|
||||
let data: String = (0..n).map(|i| format!("<f{i}>v{i}</f{i}>")).collect();
|
||||
let xdp = format!(
|
||||
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"/></template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form1><page1>{data}</page1></form1></xfa:data></xfa:datasets></xdp:xdp>"
|
||||
);
|
||||
// Objects 1–7, then the widgets from 8.
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 8)).collect();
|
||||
let kids = kids.join(" ");
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [{kids}] >>").into_bytes(),
|
||||
b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
|
||||
b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(),
|
||||
format!("<< /T (page1[0]) /Parent 6 0 R /Kids [{kids}] >>").into_bytes(),
|
||||
];
|
||||
for i in 0..n {
|
||||
let y = 760 - (i % 50) * 14;
|
||||
let x = 36 + (i / 50) * 110;
|
||||
// The first field is read-only: its data value is not taken.
|
||||
let flags = if i == 0 { " /Ff 1" } else { "" };
|
||||
objs.push(
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}[0]){flags} /Parent 7 0 R /Rect [{x} {y} {} {}] /P 3 0 R /DA (/Helv 9 Tf 0 g) >>",
|
||||
x + 100,
|
||||
y + 12
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
/// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a
|
||||
/// trailer whose root is object 1.
|
||||
fn assemble(objs: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
|
||||
@@ -99,7 +99,7 @@ pub use data::{
|
||||
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
|
||||
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
|
||||
pub use parse::{measure, parse};
|
||||
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
|
||||
pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
|
||||
pub use picture::typed_date;
|
||||
pub use script::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text,
|
||||
|
||||
+148
-11
@@ -2,7 +2,7 @@
|
||||
//! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance
|
||||
//! streams are left empty here; the forms layer regenerates them from the field dictionaries.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{literal, win_ansi};
|
||||
@@ -21,6 +21,10 @@ pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout";
|
||||
/// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the
|
||||
/// field's SOM path.
|
||||
pub const CLICK_KEY: &[u8] = b"PCXfaClick";
|
||||
/// Private key on the fields generated to name a signature field by its SOM path, one for each
|
||||
/// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the
|
||||
/// fields they hold.
|
||||
pub const NODE_KEY: &[u8] = b"PCXfaNode";
|
||||
|
||||
/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
|
||||
/// markers). `None` for anything else.
|
||||
@@ -44,7 +48,7 @@ fn hex32(h: &[u8; 32]) -> Vec<u8> {
|
||||
|
||||
fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> {
|
||||
let mut out = [0u8; 32];
|
||||
if s.len() != 64 {
|
||||
if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) {
|
||||
@@ -264,6 +268,11 @@ struct Emitter<'a> {
|
||||
warnings: Vec<String>,
|
||||
/// The signatures the file holds, by the SOM path of the field signed.
|
||||
signed: HashMap<String, Signed>,
|
||||
/// The full names of the fields the file came with, which a generated name must not repeat.
|
||||
taken: HashSet<String>,
|
||||
/// The fields above the signature fields named by their SOM paths, by the path they name:
|
||||
/// each one's object and the fields it holds.
|
||||
nodes: HashMap<String, (ObjRef, Vec<ObjRef>)>,
|
||||
}
|
||||
|
||||
/// A signature field of the file that has been signed.
|
||||
@@ -279,13 +288,16 @@ const MAX_FIELDS_SEARCHED: usize = 20_000;
|
||||
/// Deepest field nesting followed.
|
||||
const MAX_FIELD_DEPTH: usize = 32;
|
||||
|
||||
/// The signed signature fields the document came with, by full name. Signing applications name
|
||||
/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a
|
||||
/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read.
|
||||
fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap<String, Signed> {
|
||||
/// The fields the document came with: the full name of each, and the signed signature fields by
|
||||
/// SOM path. Signing applications name a dynamic form's fields by their SOM path
|
||||
/// (`form1[0].page1[0].sign[0]`), which is how a laid-out signature box finds its signature. A
|
||||
/// field an earlier layout pass generated gives no name, but a signature on it is kept, by its
|
||||
/// `/PCSom`, so a new layout keeps the box signed.
|
||||
fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>, HashSet<String>) {
|
||||
let mut out = HashMap::new();
|
||||
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out };
|
||||
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out };
|
||||
let mut names = HashSet::new();
|
||||
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) };
|
||||
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) };
|
||||
let mut stack: Vec<(Object, String, bool, usize)> =
|
||||
fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect();
|
||||
let mut seen = 0usize;
|
||||
@@ -296,7 +308,22 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap<String, Signed> {
|
||||
}
|
||||
let field = doc.resolve(&field);
|
||||
let Some(d) = field.as_dict() else { continue };
|
||||
if d.contains(NODE_KEY) {
|
||||
// Generated to name a signature field: only what it holds is looked at.
|
||||
if depth < MAX_FIELD_DEPTH {
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if d.contains(SOM_KEY) {
|
||||
let som = d.get(SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()));
|
||||
if d.name(b"FT") == Some(b"Sig")
|
||||
&& let Some(som) = som
|
||||
&& let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some())
|
||||
{
|
||||
out.entry(som).or_insert(Signed { value: value.clone(), appearance: d.get(b"AP").cloned() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) {
|
||||
@@ -304,6 +331,9 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap<String, Signed> {
|
||||
Some(t) => format!("{prefix}.{t}"),
|
||||
None => prefix,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.insert(name.clone());
|
||||
}
|
||||
let is_sig = match d.name(b"FT") {
|
||||
Some(ft) => ft == b"Sig",
|
||||
None => parent_is_sig,
|
||||
@@ -318,7 +348,7 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap<String, Signed> {
|
||||
stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1)));
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, names)
|
||||
}
|
||||
|
||||
impl Emitter<'_> {
|
||||
@@ -363,6 +393,9 @@ impl Emitter<'_> {
|
||||
ff |= 1;
|
||||
}
|
||||
let mut top_level = true;
|
||||
// The SOM path of the generated field above this one, for a signature field named by its
|
||||
// SOM path.
|
||||
let mut under: Option<String> = None;
|
||||
match &w.kind {
|
||||
WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => {
|
||||
d.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
@@ -464,6 +497,16 @@ impl Emitter<'_> {
|
||||
}
|
||||
ff |= 1;
|
||||
}
|
||||
if let Some((above, last)) = self.som_steps(&w.som)
|
||||
&& let Some(parent) = self.node(&above)
|
||||
{
|
||||
// Named by its SOM path, as fields under one for each step above it: other
|
||||
// viewers lay a dynamic form out again and find a signature by that name.
|
||||
d.set(b"T".to_vec(), Object::String(PdfString::text(&last)));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
top_level = false;
|
||||
under = Some(above.join("."));
|
||||
}
|
||||
}
|
||||
WidgetKind::Image => {
|
||||
// A push button with nothing drawn: the forms layer treats one so marked as an
|
||||
@@ -574,6 +617,12 @@ impl Emitter<'_> {
|
||||
if top_level {
|
||||
self.fields.push(r);
|
||||
self.field_count += 1;
|
||||
} else if let Some(path) = under {
|
||||
if let Some((_, kids)) = self.nodes.get_mut(&path) {
|
||||
kids.push(r);
|
||||
}
|
||||
self.taken.insert(w.som.clone());
|
||||
self.field_count += 1;
|
||||
} else if let WidgetKind::Radio { group, .. } = &w.kind
|
||||
&& let Some((_, kids)) = self.radio_groups.get_mut(group)
|
||||
{
|
||||
@@ -582,6 +631,60 @@ impl Emitter<'_> {
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
/// The steps of SOM path `som` above the last, and the last, when a field can be named by the
|
||||
/// path: two to [`MAX_FIELD_DEPTH`] steps, none empty, and no field of the file or of this
|
||||
/// pass already named by the path or by a part of it. `None` keeps the field's own name.
|
||||
fn som_steps(&self, som: &str) -> Option<(Vec<String>, String)> {
|
||||
let steps: Vec<&str> = som.split('.').collect();
|
||||
if steps.len() < 2 || steps.len() > MAX_FIELD_DEPTH || steps.iter().any(|s| s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
for i in 1..=steps.len() {
|
||||
let path = steps.get(..i)?.join(".");
|
||||
// A part already generated by this pass is shared; one the file has is not touched.
|
||||
let generated = i < steps.len() && self.nodes.contains_key(&path);
|
||||
if !generated && self.taken.contains(&path) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (last, above) = steps.split_last()?;
|
||||
Some((above.iter().map(|s| s.to_string()).collect(), last.to_string()))
|
||||
}
|
||||
|
||||
/// The generated field named by the SOM path of `steps`, made (with those above it) when this
|
||||
/// pass has none yet; the topmost goes into the AcroForm's fields. `None` for no steps.
|
||||
fn node(&mut self, steps: &[String]) -> Option<ObjRef> {
|
||||
let mut parent: Option<(ObjRef, String)> = None;
|
||||
for step in steps {
|
||||
let path = match &parent {
|
||||
Some((_, above)) => format!("{above}.{step}"),
|
||||
None => step.clone(),
|
||||
};
|
||||
let r = match self.nodes.get(&path) {
|
||||
Some((r, _)) => *r,
|
||||
None => {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"T".to_vec(), Object::String(PdfString::text(step)));
|
||||
d.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
d.set(NODE_KEY.to_vec(), Object::Bool(true));
|
||||
if let Some((p, _)) = &parent {
|
||||
d.set(b"Parent".to_vec(), Object::Ref(*p));
|
||||
}
|
||||
let r = self.doc.add(Object::Dict(d));
|
||||
match parent.as_ref().and_then(|(_, above)| self.nodes.get_mut(above)) {
|
||||
Some((_, kids)) => kids.push(r),
|
||||
None => self.fields.push(r),
|
||||
}
|
||||
self.nodes.insert(path.clone(), (r, Vec::new()));
|
||||
self.taken.insert(path.clone());
|
||||
r
|
||||
}
|
||||
};
|
||||
parent = Some((r, path));
|
||||
}
|
||||
parent.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs
|
||||
/// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
|
||||
/// for anything else.
|
||||
@@ -908,6 +1011,9 @@ fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Write laid-out `form` into `doc`: its pages replace the document's, its widgets join the
|
||||
/// AcroForm's fields (a box the file holds a signature for keeps it), and the AcroForm is marked
|
||||
/// laid out. What was written, with any warnings, comes back.
|
||||
pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError> {
|
||||
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
|
||||
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
|
||||
@@ -925,7 +1031,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
};
|
||||
// Pictures embedded by earlier passes, from the pages this pass replaces.
|
||||
let images = earlier_images(doc, pages_ref);
|
||||
let signed = signed_fields(doc, &catalog);
|
||||
let (signed, taken) = existing_fields(doc, &catalog);
|
||||
let mut em = Emitter {
|
||||
doc,
|
||||
images,
|
||||
@@ -935,7 +1041,17 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
field_count: 0,
|
||||
warnings: Vec::new(),
|
||||
signed,
|
||||
taken,
|
||||
nodes: HashMap::new(),
|
||||
};
|
||||
// Every name a field of this pass takes at the top, wherever and in whatever order it is
|
||||
// written: a signature field named by its SOM path must not repeat one.
|
||||
for w in form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }) {
|
||||
em.taken.insert(match &w.kind {
|
||||
WidgetKind::Radio { group, .. } => group.clone(),
|
||||
_ => w.name.clone(),
|
||||
});
|
||||
}
|
||||
let mut kids = Vec::new();
|
||||
for page in &form.pages {
|
||||
kids.push(Object::Ref(em.page(page, pages_ref)?));
|
||||
@@ -950,6 +1066,10 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let nodes: Vec<(ObjRef, Vec<ObjRef>)> = em.nodes.values().cloned().collect();
|
||||
for (node, kids) in nodes {
|
||||
em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?;
|
||||
}
|
||||
let Emitter { doc, fields, field_count, warnings, .. } = em;
|
||||
let count = kids.len();
|
||||
doc.update_dict(pages_ref, |d| {
|
||||
@@ -1174,7 +1294,7 @@ pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
|
||||
out.image_fields += 1;
|
||||
}
|
||||
let private: Vec<Vec<u8>> =
|
||||
[SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
|
||||
[SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
|
||||
if !private.is_empty() {
|
||||
out.field_keys += private.len();
|
||||
doc.update_dict(r, |d| {
|
||||
@@ -1294,3 +1414,20 @@ pub fn existing_layout(doc: &Document) -> Option<crate::Report> {
|
||||
pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
|
||||
[r.x, page_height - r.bottom(), r.right(), page_height - r.y]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_hex32;
|
||||
|
||||
#[test]
|
||||
fn parse_hex32_rejects_non_hex_digits() {
|
||||
for prefix in ["+f", "-f", " f"] {
|
||||
let mut s = prefix.as_bytes().to_vec();
|
||||
s.resize(64, b'0');
|
||||
assert!(parse_hex32(&s).is_none(), "{prefix:?}");
|
||||
}
|
||||
let mut ok = b"Ff".to_vec();
|
||||
ok.resize(64, b'0');
|
||||
assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff));
|
||||
}
|
||||
}
|
||||
+40
-30
@@ -586,13 +586,31 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
|
||||
if form.pages.is_empty() {
|
||||
return Err(XfaError::Malformed("the template laid out to no pages".into()));
|
||||
}
|
||||
// The AcroForm keeps only the fields PdfCraft did not generate. Any generated fields edited in
|
||||
// this overlay (along with their appearance streams and widget kids) are freed so a replaced
|
||||
// password widget's value or masked appearance cannot stay in the overlay.
|
||||
// The fields an earlier pass generated give way to the new layout's, after it is written: it
|
||||
// keeps a signature one of them holds. The AcroForm keeps every field PdfCraft did not generate.
|
||||
let Some(root) = doc.root() else { return Err(XfaError::NotXfa) };
|
||||
if let Some((acro_ref, mut acro)) = acroform(doc) {
|
||||
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
let mut kept = Vec::new();
|
||||
let top = |doc: &Document| -> Vec<Object> {
|
||||
acroform(doc).and_then(|(_, acro)| acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned())).unwrap_or_default()
|
||||
};
|
||||
let generated: std::collections::HashSet<pdfcraft_cos::ObjRef> = top(doc)
|
||||
.iter()
|
||||
.filter_map(Object::as_ref)
|
||||
.filter(|r| doc.get(*r).as_dict().is_some_and(|d| d.contains(crate::pdf::SOM_KEY) || d.contains(crate::pdf::NODE_KEY)))
|
||||
.collect();
|
||||
let written = crate::pdf::write_form(doc, &form)?;
|
||||
if !generated.is_empty()
|
||||
&& let Some((acro_ref, mut acro)) = acroform(doc)
|
||||
{
|
||||
let kept: Vec<Object> = top(doc).into_iter().filter(|f| f.as_ref().is_none_or(|r| !generated.contains(&r))).collect();
|
||||
acro.set(b"Fields".to_vec(), Object::Array(kept));
|
||||
match acro_ref {
|
||||
Some(r) => doc.set(r, Object::Dict(acro)),
|
||||
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
|
||||
}
|
||||
// A generated field edited in this overlay is freed with its appearance and widget kids,
|
||||
// so a replaced password widget's value or masked appearance cannot stay in the overlay.
|
||||
// Signature fields, and the fields that name them by their SOM path, stay: the new
|
||||
// layout's signature field took their value and appearance.
|
||||
let free_ap = |doc: &mut Document, d: &Dict| {
|
||||
if let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned())
|
||||
&& let Some(n) = ap.get(b"N").and_then(Object::as_ref)
|
||||
@@ -601,36 +619,28 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
|
||||
doc.free(n);
|
||||
}
|
||||
};
|
||||
for f in fields {
|
||||
if let Some(r) = f.as_ref()
|
||||
&& let Some(d) = doc.get(r).as_dict().cloned()
|
||||
&& d.contains(crate::pdf::SOM_KEY)
|
||||
{
|
||||
free_ap(doc, &d);
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
for k in kids.iter().filter_map(Object::as_ref) {
|
||||
if let Some(kd) = doc.get(k).as_dict().cloned() {
|
||||
free_ap(doc, &kd);
|
||||
}
|
||||
if doc.is_edited(k.num) {
|
||||
doc.free(k);
|
||||
let signature = |d: &Dict| d.name(b"FT") == Some(b"Sig".as_slice()) || d.contains(crate::pdf::NODE_KEY);
|
||||
for r in generated {
|
||||
let Some(d) = doc.get(r).as_dict().cloned().filter(|d| !signature(d)) else { continue };
|
||||
free_ap(doc, &d);
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
for k in kids.iter().filter_map(Object::as_ref) {
|
||||
if let Some(kd) = doc.get(k).as_dict().cloned() {
|
||||
if signature(&kd) {
|
||||
continue;
|
||||
}
|
||||
free_ap(doc, &kd);
|
||||
}
|
||||
if doc.is_edited(k.num) {
|
||||
doc.free(k);
|
||||
}
|
||||
}
|
||||
if doc.is_edited(r.num) {
|
||||
doc.free(r);
|
||||
}
|
||||
} else {
|
||||
kept.push(f);
|
||||
}
|
||||
if doc.is_edited(r.num) {
|
||||
doc.free(r);
|
||||
}
|
||||
}
|
||||
acro.set(b"Fields".to_vec(), Object::Array(kept));
|
||||
match acro_ref {
|
||||
Some(r) => doc.set(r, Object::Dict(acro)),
|
||||
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
|
||||
}
|
||||
}
|
||||
let written = crate::pdf::write_form(doc, &form)?;
|
||||
let mut warnings = form.warnings;
|
||||
warnings.extend(written.warnings);
|
||||
if written.usage_rights {
|
||||
|
||||
+217
-1
@@ -1508,6 +1508,8 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Choice lists, a password, a signature and pictures in the template become their kinds of
|
||||
/// widget, and their AcroForm fields and image XObjects once written.
|
||||
#[test]
|
||||
fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||||
let form = layout_xml(&fields_template("")).unwrap();
|
||||
@@ -1570,7 +1572,11 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||||
assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string");
|
||||
assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box");
|
||||
assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field");
|
||||
assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice()));
|
||||
// The signature field is named by its SOM path, under a generated field for each step above it.
|
||||
assert!(fields.iter().all(|(name, _)| name != "sign"));
|
||||
let named = full_names(&doc);
|
||||
let sign = named.iter().find(|(name, _)| name == "form[0].page1[0].sign[0]").map(|(_, d)| d).expect("named by its SOM path");
|
||||
assert_eq!(sign.name(b"FT"), Some(b"Sig".as_slice()));
|
||||
let streams: Vec<pdfcraft_cos::Stream> = doc
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
@@ -3009,6 +3015,213 @@ fn a_zero_caption_reserve_sizes_the_caption_as_none_does() {
|
||||
assert!((x_of("withZero") - x_of("without")).abs() < 0.01, "as without a reserve: {} {}", x_of("withZero"), x_of("without"));
|
||||
}
|
||||
|
||||
/// The terminal fields by full name, down the field tree.
|
||||
fn full_names(doc: &Document) -> Vec<(String, Dict)> {
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let mut todo: Vec<(Object, String)> =
|
||||
doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().map(|f| (f.clone(), String::new())).collect();
|
||||
let mut out = Vec::new();
|
||||
while let Some((f, prefix)) = todo.pop() {
|
||||
let d = doc.resolve(&f).as_dict().unwrap().clone();
|
||||
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text()));
|
||||
let name = match t {
|
||||
Some(t) if prefix.is_empty() => t,
|
||||
Some(t) => format!("{prefix}.{t}"),
|
||||
None => prefix,
|
||||
};
|
||||
match d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
Some(kids) if kids.iter().any(|k| doc.resolve(k).as_dict().is_some_and(|k| k.contains(b"T"))) => {
|
||||
todo.extend(kids.into_iter().map(|k| (k, name.clone())));
|
||||
}
|
||||
_ => out.push((name, d)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A laid-out signature field is named by its SOM path, under a generated field for each step
|
||||
/// above it, as other viewers lay a dynamic form out again and find its signature by that name.
|
||||
/// Boxes in one subform share those fields; a new layout replaces them rather than adding more;
|
||||
/// taking the XFA out leaves them as ordinary fields.
|
||||
#[test]
|
||||
fn signature_fields_are_named_by_their_som_path() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="body" layout="tb" w="7.5in">
|
||||
<field name="name" w="3in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>
|
||||
<field name="other" w="3in" h="1in"><ui><signature/></ui></field>
|
||||
</subform>
|
||||
<subform name="end" layout="tb" w="7.5in">
|
||||
<field name="last" w="3in" h="1in"><ui><signature/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!(report.fields, 4, "name and the three signature fields");
|
||||
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let check = |doc: &Document| {
|
||||
let top: Vec<String> = pdfcraft_forms_free_fields(doc).into_iter().map(|(name, _)| name).collect();
|
||||
assert_eq!(top.iter().filter(|n| *n == "form[0]").count(), 1, "one field for the root subform: {top:?}");
|
||||
assert!(top.iter().any(|n| n == "name"), "other fields keep their names: {top:?}");
|
||||
let named = full_names(doc);
|
||||
for som in ["form[0].body[0].sign[0]", "form[0].body[0].other[0]", "form[0].end[0].last[0]"] {
|
||||
let (_, d) = named.iter().find(|(name, _)| name == som).unwrap_or_else(|| panic!("{som} in {named:?}"));
|
||||
assert_eq!(d.name(b"FT"), Some(b"Sig".as_slice()));
|
||||
assert_eq!(fields_by_som(doc).get(som).map(String::as_str), Some(som), "the data finds it by its SOM path");
|
||||
}
|
||||
assert_eq!(named.len(), 4, "{named:?}");
|
||||
};
|
||||
check(&doc);
|
||||
let body = |doc: &Document| {
|
||||
let named = full_names(doc);
|
||||
let parent = |som: &str| named.iter().find(|(name, _)| name == som).and_then(|(_, d)| d.get(b"Parent").and_then(Object::as_ref));
|
||||
(parent("form[0].body[0].sign[0]"), parent("form[0].body[0].other[0]"))
|
||||
};
|
||||
let (a, b) = body(&doc);
|
||||
assert!(a.is_some() && a == b, "the two boxes of one subform share its field");
|
||||
// Laid out again: the same fields, not a second set.
|
||||
let mut doc = doc;
|
||||
let tpl = crate::script::template_of(&doc).unwrap();
|
||||
rerender(&mut doc, &tpl).unwrap();
|
||||
check(&doc);
|
||||
// The XFA taken out: ordinary fields with the same names, without the private key.
|
||||
strip(&mut doc).unwrap();
|
||||
let named = full_names(&doc);
|
||||
assert!(named.iter().any(|(name, _)| name == "form[0].end[0].last[0]"), "{named:?}");
|
||||
let top = pdfcraft_forms_free_fields(&doc);
|
||||
let node = top.iter().find(|(name, _)| name == "form[0]").map(|(_, d)| d.clone()).expect("the root subform's field");
|
||||
assert!(!node.contains(NODE_KEY));
|
||||
}
|
||||
|
||||
/// The terminal field named `full`, down the field tree.
|
||||
fn field_ref(doc: &Document, full: &str) -> Option<pdfcraft_cos::ObjRef> {
|
||||
let root = doc.get(doc.root()?);
|
||||
let acro = doc.resolve(root.as_dict()?.get(b"AcroForm")?);
|
||||
let mut todo: Vec<(pdfcraft_cos::ObjRef, String)> =
|
||||
doc.resolve(acro.as_dict()?.get(b"Fields")?).as_array()?.iter().filter_map(Object::as_ref).map(|r| (r, String::new())).collect();
|
||||
while let Some((r, prefix)) = todo.pop() {
|
||||
let d = doc.get(r).as_dict()?.clone();
|
||||
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())).unwrap_or_default();
|
||||
let name = if prefix.is_empty() { t } else { format!("{prefix}.{t}") };
|
||||
if name == full {
|
||||
return Some(r);
|
||||
}
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
todo.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, name.clone())));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A box signed on a laid-out form stays signed when the form is laid out again (as after a
|
||||
/// script adds a row): the new layout's field, named by the same SOM path, holds the same
|
||||
/// signature and is locked, and the old field is gone from the AcroForm.
|
||||
#[test]
|
||||
fn a_signature_on_a_laid_out_box_survives_a_new_layout() {
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let som = "form[0].page1[0].sign[0]";
|
||||
let old = field_ref(&doc, som).expect("the box");
|
||||
// Signed as signing does it: a signature dictionary as the field's value, and a picture.
|
||||
let mut sig = Dict::new();
|
||||
sig.set(b"Type".to_vec(), Object::name("Sig"));
|
||||
let sig = doc.add(Object::Dict(sig));
|
||||
let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec())));
|
||||
doc.update_dict(old, |d| {
|
||||
d.set(b"V".to_vec(), Object::Ref(sig));
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Ref(picture));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
})
|
||||
.unwrap();
|
||||
let tpl = crate::script::template_of(&doc).unwrap();
|
||||
rerender(&mut doc, &tpl).unwrap();
|
||||
let new = field_ref(&doc, som).expect("still named by its SOM path");
|
||||
assert_ne!(new, old, "a field of the new layout");
|
||||
let d = doc.get(new).as_dict().unwrap().clone();
|
||||
assert_eq!(d.get(b"V").and_then(Object::as_ref), Some(sig), "the same signature");
|
||||
let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref)));
|
||||
assert_eq!(shown, Some(picture), "and the same picture");
|
||||
assert!(matches!(&*doc.get(picture), Object::Stream(_)), "which is still in the file");
|
||||
assert!(matches!(&*doc.get(sig), Object::Dict(_)), "as is the signature");
|
||||
assert_eq!(d.int(b"Ff").unwrap_or(0) & 1, 1, "a signed box is not signed again");
|
||||
let top = pdfcraft_forms_free_fields(&doc);
|
||||
assert_eq!(top.iter().filter(|(name, _)| name == "form[0]").count(), 1, "the old fields are gone");
|
||||
}
|
||||
|
||||
/// A box right in the form's root subform, signed and then laid out again, keeps its signature
|
||||
/// and picture in the file: its field sits just under the one naming it, and the old fields that
|
||||
/// give way to the new layout's (freed when this session made them) take nothing it still uses.
|
||||
#[test]
|
||||
fn a_signed_box_in_the_root_subform_keeps_its_picture_through_a_new_layout() {
|
||||
let xdp = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb"><pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>
|
||||
</subform></template></xdp:xdp>"#;
|
||||
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let som = "form[0].sign[0]";
|
||||
let old = field_ref(&doc, som).expect("the box");
|
||||
let sig = doc.add(Object::Dict(Dict::new()));
|
||||
let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec())));
|
||||
doc.update_dict(old, |d| {
|
||||
d.set(b"V".to_vec(), Object::Ref(sig));
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Ref(picture));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
})
|
||||
.unwrap();
|
||||
let tpl = crate::script::template_of(&doc).unwrap();
|
||||
rerender(&mut doc, &tpl).unwrap();
|
||||
let new = field_ref(&doc, som).expect("still named by its SOM path");
|
||||
let d = doc.get(new).as_dict().unwrap().clone();
|
||||
let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref)));
|
||||
assert_eq!(shown, Some(picture));
|
||||
assert!(matches!(&*doc.get(picture), Object::Stream(_)), "the picture is still in the file");
|
||||
assert!(matches!(&*doc.get(sig), Object::Dict(_)), "and the signature");
|
||||
}
|
||||
|
||||
/// A field of the same pass keeps a signature field named by its SOM path from repeating its
|
||||
/// name, written before the box or after it: with a text field named `form[0]` (as a hostile
|
||||
/// template may name one), the box keeps its own name and no two fields share one.
|
||||
#[test]
|
||||
fn a_signature_field_does_not_repeat_a_name_another_field_has() {
|
||||
let text = r#"<field name="form[0]" w="3in" h="0.3in"><ui><textEdit/></ui></field>"#;
|
||||
let sign = r#"<field name="sign" w="3in" h="1in"><ui><signature/></ui></field>"#;
|
||||
for (first, second) in [(text, sign), (sign, text)] {
|
||||
let xdp = format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="body" layout="tb" w="7.5in">{first}{second}</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
);
|
||||
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let mut top: Vec<String> = pdfcraft_forms_free_fields(&doc).into_iter().map(|(name, _)| name).collect();
|
||||
assert!(top.iter().any(|n| n == "sign"), "the box keeps its own name: {top:?}");
|
||||
top.sort();
|
||||
let len = top.len();
|
||||
top.dedup();
|
||||
assert_eq!(top.len(), len, "no name twice: {top:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature box the file already holds a signature for shows that signature, the file's own
|
||||
/// objects, and is locked; the other box stays unsigned.
|
||||
#[test]
|
||||
fn a_signed_signature_box_shows_the_signature_the_file_holds() {
|
||||
let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap();
|
||||
@@ -3017,7 +3230,10 @@ fn a_signed_signature_box_shows_the_signature_the_file_holds() {
|
||||
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
|
||||
let laid: Vec<_> = fields.iter().map(|f| doc.resolve(f)).filter(|f| f.as_dict().unwrap().contains(SOM_KEY)).collect();
|
||||
// The file has a field named `form[0]` already: the unsigned box keeps its own name rather
|
||||
// than repeat that one.
|
||||
let (signed, unsigned) = (laid[0].as_dict().unwrap(), laid[1].as_dict().unwrap());
|
||||
assert_eq!(unsigned.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text()).as_deref(), Some("other"));
|
||||
// The signature dictionary and the picture are the file's own objects, not copies.
|
||||
let value = doc.resolve(signed.get(b"V").unwrap());
|
||||
assert_eq!(value.as_dict().unwrap().name(b"Type"), Some(&b"Sig"[..]));
|
||||
|
||||
+33
-14
@@ -1126,6 +1126,24 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result<ObjRef, Da
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A pop-up window for the comment `parent`, on its page.
|
||||
fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: &[f64], open: bool) -> Result<(), DataError> {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
p.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
p.set(b"Rect".to_vec(), arr(rect));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
p.set(b"Open".to_vec(), Object::Bool(open));
|
||||
let pr = doc.add(Object::Dict(p));
|
||||
doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?;
|
||||
doc.update_dict(page, |pd| {
|
||||
if let Some(Object::Array(a)) = pd.get_mut(b"Annots") {
|
||||
a.push(Object::Ref(pr));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
check_form_xml_depth(text)?;
|
||||
let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?;
|
||||
@@ -1157,19 +1175,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
if let Some(pop) = node.children().find(|c| c.has_tag_name("popup"))
|
||||
&& let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4)
|
||||
{
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
p.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
p.set(b"Rect".to_vec(), arr(&rect));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(r));
|
||||
p.set(b"Open".to_vec(), Object::Bool(pop.attribute("open") == Some("yes")));
|
||||
let pr = doc.add(Object::Dict(p));
|
||||
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?;
|
||||
doc.update_dict(page, |pd| {
|
||||
if let Some(Object::Array(a)) = pd.get_mut(b"Annots") {
|
||||
a.push(Object::Ref(pr));
|
||||
}
|
||||
})?;
|
||||
add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?;
|
||||
}
|
||||
report.comments += 1;
|
||||
}
|
||||
@@ -1276,7 +1282,11 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec<Str
|
||||
// Comments and processing instructions can split a value into several text nodes.
|
||||
.map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect())
|
||||
.collect();
|
||||
if !vals.is_empty() {
|
||||
// A leaf with no `<value>` is an empty field (the export writes one that way), so it
|
||||
// clears the field like FDF's `/V ()`; a field with kids only names their parent. A
|
||||
// leaf holding only `<value-richtext>` is not read yet, so it leaves the field alone.
|
||||
let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext"));
|
||||
if !vals.is_empty() || !names_kids_or_rich {
|
||||
out.push((full.clone(), vals));
|
||||
}
|
||||
walk_xfdf(f, &full, out, depth + 1)?;
|
||||
@@ -1445,6 +1455,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
|
||||
// The parent is another comment: link it below rather than copying it in.
|
||||
let irt = src.get(b"IRT").cloned();
|
||||
src.remove(b"IRT");
|
||||
// The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO
|
||||
// 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is
|
||||
// placed on the page below, as the XFDF import does.
|
||||
let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned());
|
||||
src.remove(b"Popup");
|
||||
let mut path: Vec<ObjRef> = a.as_ref().into_iter().collect();
|
||||
let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue };
|
||||
let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else {
|
||||
@@ -1457,6 +1472,10 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
|
||||
d.set(b"P".to_vec(), Object::Ref(page));
|
||||
let r = place(doc, page, d)?;
|
||||
later.remove(&r);
|
||||
if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) {
|
||||
let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true)));
|
||||
add_popup(doc, page, r, &rect, open)?;
|
||||
}
|
||||
if let Some(fr) = a.as_ref() {
|
||||
placed.insert(fr, r);
|
||||
}
|
||||
|
||||
@@ -1933,3 +1933,84 @@ fn annots_of(doc: &Document) -> Vec<Dict> {
|
||||
.filter(|d| d.get(b"Subtype") != Some(&Object::name("Widget")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An empty text field exports as a `<field>` with no `<value>`, and the import skipped such a
|
||||
/// field, so importing the data left the target's old text in place. FDF, XML, CSV and text
|
||||
/// carry the same empty value and clear the field.
|
||||
#[test]
|
||||
fn an_empty_field_in_xfdf_clears_the_field_it_is_imported_into() {
|
||||
let src = one_field();
|
||||
assert_eq!(values(&src), vec![("Name".to_string(), Vec::<String>::new())]);
|
||||
let exports = [
|
||||
("xfdf", export_xfdf(&src, false, true, "form.pdf").into_bytes()),
|
||||
("fdf", export_fdf(&src, false, true, "form.pdf").unwrap()),
|
||||
("xml", export_data(&src, Format::Xml).into_bytes()),
|
||||
];
|
||||
for (format, data) in exports {
|
||||
let mut dst = one_field();
|
||||
set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap();
|
||||
let report = import(&mut dst, &data);
|
||||
assert_eq!(values(&dst), values(&src), "{format}: {report:?}: {}", String::from_utf8_lossy(&data));
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf holding only `<value-richtext>` is not read by the import, so it must leave the field
|
||||
/// as it was rather than clear it like an empty `<field>`.
|
||||
#[test]
|
||||
fn a_richtext_only_field_in_xfdf_leaves_the_field_alone() {
|
||||
let data = br#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xfdf xmlns="http://ns.adobe.com/xfdf/"><fields><field name="Name"><value-richtext><body xmlns="http://www.w3.org/1999/xhtml"><p>Bo</p></body></value-richtext></field></fields></xfdf>"#;
|
||||
let mut dst = one_field();
|
||||
set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap();
|
||||
let report = import(&mut dst, data);
|
||||
assert_eq!(values(&dst), vec![("Name".to_string(), vec!["Ada".to_string()])], "{report:?}");
|
||||
}
|
||||
|
||||
/// An FDF comment's pop-up is an annotation of its own (`/Popup` → a `/Subtype /Popup` object
|
||||
/// with `/Parent`). The import copied the pop-up's dictionary into the comment as a direct value,
|
||||
/// which ISO 32000-2 Table 172 says shall be an indirect reference to an annotation on the page.
|
||||
/// The import now places a pop-up of its own, as the XFDF import does, keeping its place and
|
||||
/// open state.
|
||||
#[test]
|
||||
fn an_fdf_comment_popup_is_not_copied_in_as_a_direct_dictionary() {
|
||||
let fdf = fdf_with(
|
||||
"/Annots [2 0 R 3 0 R]",
|
||||
&[
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 10 140 60] /Parent 2 0 R /Page 0 /Open true >>".into(),
|
||||
],
|
||||
);
|
||||
let mut dst = blank();
|
||||
assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1);
|
||||
let note = find_by_name(&dst, "n").unwrap();
|
||||
let popup = dst.get(note).as_dict().unwrap().get(b"Popup").cloned();
|
||||
let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
let pr = popup.as_ref().and_then(Object::as_ref).filter(|r| page.contains(&Object::Ref(*r)));
|
||||
let pr = pr.unwrap_or_else(|| panic!("/Popup must be a reference to an annotation on the page: {popup:?}"));
|
||||
let pd = dst.get(pr).as_dict().cloned().unwrap();
|
||||
assert_eq!(pd.name(b"Subtype"), Some(b"Popup".as_slice()));
|
||||
assert_eq!(pd.get(b"Parent"), Some(&Object::Ref(note)));
|
||||
assert_eq!(pd.get(b"Open"), Some(&Object::Bool(true)));
|
||||
let rect: Vec<f64> = pd.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect();
|
||||
assert_eq!(rect, [40.0, 10.0, 140.0, 60.0]);
|
||||
}
|
||||
|
||||
/// A pop-up without a usable `/Rect` cannot be placed: the comment still imports, with no
|
||||
/// `/Popup` at all (neither the FDF's dictionary copied in nor a pop-up on the page).
|
||||
#[test]
|
||||
fn an_fdf_comment_popup_without_a_rect_is_dropped() {
|
||||
let fdf = fdf_with(
|
||||
"/Annots [2 0 R 3 0 R]",
|
||||
&[
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Popup /Parent 2 0 R /Page 0 /Open true >>".into(),
|
||||
],
|
||||
);
|
||||
let mut dst = blank();
|
||||
assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1);
|
||||
let note = find_by_name(&dst, "n").unwrap();
|
||||
assert!(dst.get(note).as_dict().unwrap().get(b"Popup").is_none());
|
||||
let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count();
|
||||
assert_eq!(popups, 0);
|
||||
}
|
||||
@@ -38,7 +38,9 @@ the full placement by the same change; resizing keeps the opposite corner fixed,
|
||||
horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not
|
||||
jump. A selected corner keeps control of its drag even over a text label; the drag previews one
|
||||
frame and applies one undoable edit on release. Exclusive crops and their holes move
|
||||
with the selected image; shared clips stay fixed without moving neighbouring text or artwork.
|
||||
with the selected image, also when the picture sits inside a clip it shares with its section
|
||||
(a report drawing each picture as `q re W n cm Do Q` within a table's clip): the picture's own
|
||||
clip scales with it, and shared clips stay fixed without moving neighbouring text or artwork.
|
||||
|
||||
`image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as
|
||||
the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> Historical qualification record: the source and lockfile identities below describe earlier migration checkpoints. The current main reconciliation changes workspace versions; its exact source, lockfile hash and validation are recorded in the publication packet. Earlier results do not qualify that combined tree.
|
||||
|
||||
# PdfCraft — egui 0.37 porting plan
|
||||
|
||||
Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `418ac6a2`, including suppression of page link hover while dragging a panel, page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused pointer regressions plus CI recorded 5,130 passing test executions, zero failures and 28 ignored across 143 summaries. The dragged-panel link regression passes on egui 0.37; independent review accepted preservation of the upstream fix. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required.
|
||||
|
||||
+23
-21
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Arabic current state: logical catalog, explicit RTL label direction and mirrored stock chrome are implemented and unit-tested; native/browser/IME and complex RTL workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to
|
||||
add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md).
|
||||
@@ -13,7 +13,7 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R
|
||||
**That union is a moving target**: every change that adds a string widens it, so a count is only
|
||||
comparable with others measured on the same date, and a catalog that reached 100% falls behind
|
||||
without losing a single translation. Every figure here therefore carries its date. The current
|
||||
union is **2,674** keys as of 2026-10-20, against the **2,143-key snapshot** of 2026-10-10; earlier
|
||||
union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier
|
||||
revisions quote the union of their own date (see the revision history), not this one. The
|
||||
twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language
|
||||
except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is
|
||||
@@ -35,16 +35,16 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 |
|
||||
| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 |
|
||||
| Spanish | `es` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 |
|
||||
| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 |
|
||||
| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 |
|
||||
| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic and Kurdish labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 |
|
||||
| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 |
|
||||
| French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 |
|
||||
| Portuguese (Brazil) | `pt-br` | 2,674 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 |
|
||||
| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 |
|
||||
| Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 |
|
||||
| Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 |
|
||||
| German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 |
|
||||
| Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 |
|
||||
| Vietnamese | `vi` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 |
|
||||
| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 |
|
||||
|
||||
**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only
|
||||
60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and
|
||||
@@ -63,25 +63,26 @@ help/docs polish (catalog and script work are already done), not missing UI stri
|
||||
| Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes |
|
||||
| Russian | `ru` | 2,043 (95.3%) | partial | yes |
|
||||
| Bulgarian | `bg` | 2,094 (97.7%) | partial | no |
|
||||
| Czech | `cs` | 2,674 entries (every key of the other catalogs, the engine's sentences, weekdays and certificate fields) | partial | yes |
|
||||
| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes |
|
||||
| Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no |
|
||||
| Hungarian | `hu` | 2,126 (99.2%) | partial | yes |
|
||||
| Ukrainian | `uk` | 2,142 (100%) | partial | yes |
|
||||
| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes |
|
||||
| Kazakh | `kk` | 2,327 entries | partial | no |
|
||||
| Polish | `pl` | 2,287 entries | partial | yes |
|
||||
| Slovenian | `sl` | 2,324 entries | partial | no |
|
||||
| Greek | `el` | 2,341 entries | partial | no |
|
||||
| Turkish | `tr` | 2,313 entries | partial | yes |
|
||||
| Lithuanian | `lt` | 2,674 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
|
||||
| Finnish | `fi` | 2,674 entries | partial; AI-assisted, native proofreading pending | yes |
|
||||
| Albanian | `sq` | 2,344 entries | partial; not yet reviewed by a native speaker | no |
|
||||
| Catalan | `ca` | 2,592 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no |
|
||||
| Romanian | `ro` | 2,673 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
|
||||
| Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes |
|
||||
| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes |
|
||||
| Kazakh | `kk` | 2,539 entries | partial | no |
|
||||
| Polish | `pl` | 2,611 entries | partial | yes |
|
||||
| Slovenian | `sl` | 2,536 entries | partial | no |
|
||||
| Greek | `el` | 2,545 entries | partial | no |
|
||||
| Turkish | `tr` | 2,525 entries | partial | yes |
|
||||
| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
|
||||
| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes |
|
||||
| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no |
|
||||
| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no |
|
||||
| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
|
||||
| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes |
|
||||
| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
|
||||
|
||||
**19 other languages** ship (33 catalogs plus English = **34** interface languages in Preferences).
|
||||
The entry counts without percentages were measured on 2026-10-12–14 (Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review.
|
||||
**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences).
|
||||
The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review.
|
||||
Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat.
|
||||
|
||||
## Revision history
|
||||
@@ -89,6 +90,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged |
|
||||
| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed |
|
||||
| 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% |
|
||||
| 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date |
|
||||
| 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates |
|
||||
|
||||
+16
-14
@@ -1,10 +1,10 @@
|
||||
# Interface language
|
||||
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
|
||||
Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable.
|
||||
|
||||
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
|
||||
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one, and Persian locales (`fa`, `fa_IR`, `fa_IR.UTF-8`, `fa-AF`, ...) the Persian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
|
||||
|
||||
The control channel exposes the setting through `ui.set`:
|
||||
|
||||
@@ -12,44 +12,46 @@ The control channel exposes the setting through `ui.set`:
|
||||
{"method":"ui.set","params":{"key":"language","value":"ja"}}
|
||||
```
|
||||
|
||||
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
|
||||
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
|
||||
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Kurdish, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Romanian, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each), and Czech (2,674 entries) covers the same surface. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs carry most of the current localizable surface (roughly 2,400–2,800 entries each); Czech (2,794 entries) has all but four keys, and Finnish, Vietnamese, Catalan, Brazilian Portuguese, Spanish, Lithuanian and Romanian have all 2,798. Persian has 2,785. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
|
||||
|
||||
Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words).
|
||||
|
||||
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
|
||||
The Ukrainian catalog has 2,530 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
|
||||
|
||||
The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation.
|
||||
The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation.
|
||||
|
||||
The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame.
|
||||
The Vietnamese catalog has 2,798 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame.
|
||||
|
||||
The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker.
|
||||
The Finnish catalog has 2,798 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker.
|
||||
|
||||
The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation.
|
||||
The Catalan catalog has 2,798 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation.
|
||||
|
||||
The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame.
|
||||
The Romanian catalog (`ro`, Română) has 2,798 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame.
|
||||
|
||||
The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line.
|
||||
The Norwegian Bokmål catalog has 2,707 entries, all but 91 of the current key union, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line.
|
||||
|
||||
Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases includes the `Hebr` face, so releases draw the missing letters and accept Hebrew in Add text.
|
||||
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese uses the pinned craft-fonts input too. On native builds, `fonts/simplified-chinese-manifest.txt` adds LXGW WenKai Lite as a `Hans` face (at most 16 MiB, never embedded on the web); Simplified Chinese puts the `Hans` faces first, so a line never mixes faces with different baselines. Traditional Chinese has no embedded `Hant` face: the desktop app places a Traditional Chinese face already installed on the machine (Microsoft JhengHei on Windows, PingFang TC on macOS, a Noto CJK TC or WenQuanYi Zen Hei on Linux) before the Japanese faces, even when LXGW is bundled, because a Simplified-only face cannot supply Traditional-only characters. Without an embedded `Hans` face, Simplified Chinese likewise uses an installed Simplified Chinese face (Microsoft YaHei, PingFang SC, …) placed before Japanese (#826). The web build has no installed fonts to fall back on, so it shows a replacement box for characters the Japanese face lacks. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). A build supplied with a craft-fonts `Telu` face draws Telugu and retains that face in the web payload; egui shapes its conjuncts. The release-pinned craft-fonts input has no Telugu face, so its Telugu labels show replacement boxes.
|
||||
|
||||
The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`).
|
||||
The Brazilian Portuguese catalog has 2,798 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`).
|
||||
|
||||
The Albanian catalog (`sq.tsv`, interface name **Shqip**) is written in Standard Albanian with the imperative for commands (`Ruaj`, `Hap`, `Fshi`). Albanian uses two plural forms like English: 1 is singular and every other count, including 0, is plural (`1 faqe`, `2 faqe`). Month and weekday names are lowercase (`mars`, `e shtunë`), and the letters `ë` and `ç` are covered by the bundled Latin faces. It has not been reviewed by a native Albanian speaker yet.
|
||||
|
||||
## Arabic and right-to-left text
|
||||
|
||||
The Arabic catalog has 2,137 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months.
|
||||
The Arabic catalog has 2,783 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months.
|
||||
|
||||
egui 0.37's epaint shapes every line with the Unicode bidirectional algorithm (UAX #9). Catalog entries stay in logical (typing) order and are never preordered: `tl!`, `tr` and `trn` return logical text, `bidi::display_rtl` and `bidi::visual` exist only for the tests that prove the old preordering is gone, and epaint joins and reorders Arabic on every line. Interface labels use an explicit `TextDirection::RightToLeft` in an Arabic or Kurdish interface, so a label whose first strong character is Latin or a `{placeholder}` still reads right to left. File and path labels use `TextDirection::LeftToRight`, so an Arabic file name keeps `.pdf` at its visual end; document-authored text uses `Auto`. Stock chrome is mirrored through `Style::right_to_left` (menus, combo boxes, scroll bars, headers), and `i18n::fmt` inserts values in one pass. The command search matches a query of several Arabic words word by word.
|
||||
egui 0.37's epaint shapes every line with the Unicode bidirectional algorithm (UAX #9). Catalog entries stay in logical (typing) order and are never preordered: `tl!`, `tr` and `trn` return logical text, `bidi::display_rtl` and `bidi::visual` exist only for the tests that prove the old preordering is gone, and epaint joins and reorders Arabic on every line. Interface labels use an explicit `TextDirection::RightToLeft` in an Arabic, Kurdish or Persian interface, so a label whose first strong character is Latin or a `{placeholder}` still reads right to left. File and path labels use `TextDirection::LeftToRight`, so an Arabic file name keeps `.pdf` at its visual end; document-authored text uses `Auto`. Stock chrome is mirrored through `Style::right_to_left` (menus, combo boxes, scroll bars, headers), and `i18n::fmt` inserts values in one pass. The command search matches a query of several Arabic words word by word.
|
||||
|
||||
Wrapped paragraphs break between words in logical order and epaint reorders each resulting line, so a wrapped label reads in the right direction from the first line down. Text typed into a field is shaped and reordered while editing, and the stored value stays logical.
|
||||
|
||||
Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` selects the explicit right-to-left direction; epaint reorders it, so it behaves as Arabic does above. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`).
|
||||
|
||||
The Persian catalog (`fa.tsv`, interface name **فارسی**) has 2,785 entries, covering 2,785 of the current 2,798-key union; the 13 keys added after it was written still show English. It is written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish: `rtl: true` selects the explicit right-to-left direction, the entries stay in logical order and epaint reorders each line, and the stock chrome is mirrored as it is for Arabic. Compound words use the zero-width non-joiner (نیمفاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame.
|
||||
|
||||
Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic and Hebrew faces. Its Noto Sans CJK SC face is 16.4 MB and is left out by the general CJK cap; native builds take the separately pinned Simplified Chinese face (LXGW WenKai Lite) from `fonts/simplified-chinese-manifest.txt`, and the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces.
|
||||
|
||||
File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order.
|
||||
|
||||
@@ -254,6 +254,7 @@ Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
|
||||
@@ -758,8 +758,8 @@ milestone = "M2"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"]
|
||||
notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
|
||||
evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"]
|
||||
notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-knockout"
|
||||
@@ -818,8 +818,8 @@ milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"]
|
||||
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"]
|
||||
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-error-isolation"
|
||||
@@ -1274,8 +1274,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
|
||||
notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_act_on_the_selected_pages", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_insert_from_a_file_and_extract", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_are_off_without_page_editing_permission"]
|
||||
notes = "Page tools menu in the panel's header (#4415): insert a blank page or pages from a file after the selection, rotate either way, extract and delete, each on the selected pages (the current one when none is), greyed out where the document's security withholds page changes. Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
|
||||
|
||||
[[feature]]
|
||||
id = "view.thumbnail-context-ops"
|
||||
@@ -1322,8 +1322,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted"]
|
||||
notes = ""
|
||||
evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"]
|
||||
notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)."
|
||||
|
||||
[[feature]]
|
||||
id = "view.layers-panel"
|
||||
@@ -2153,8 +2153,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["image_edit", "image_hit"]
|
||||
evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"]
|
||||
notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
|
||||
evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"]
|
||||
notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-extract"
|
||||
@@ -2448,7 +2448,7 @@ status = "shipped"
|
||||
commands = ["edit.header_footer"]
|
||||
tools = ["doc_header_footer"]
|
||||
evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
|
||||
notes = "<<1>>, <<n>>, <<1 of n>>, <<Page 1 of n>>, dates, Bates"
|
||||
notes = "<<1>>, <<n>>, <<1 of n>>, <<Page 1 of n>>, dates (US, ISO, day-first with slashes and the dotted <<dd.mm.yyyy>>, <<d.m.yyyy>>, <<dd.mm.yy>>, <<d.m.yy>>), Bates. Month names are English only"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.header-footer-ranges"
|
||||
@@ -3507,8 +3507,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["file.properties"]
|
||||
tools = ["doc_initial_view"]
|
||||
evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination"]
|
||||
notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two, and shows page 1 for a named destination"
|
||||
evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"]
|
||||
notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.custom-properties"
|
||||
@@ -4463,8 +4463,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"]
|
||||
notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/view.rs::round_check_boxes_get_a_round_highlight"]
|
||||
notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (the tint and border round for radio buttons and for check boxes laid out for a round XFA check button); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
|
||||
|
||||
[[feature]]
|
||||
id = "form.field-appearance-display"
|
||||
@@ -5296,8 +5296,8 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/testsLine truncated
|
||||
notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first sectLine truncated
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/testsLine truncated
|
||||
notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scriptLine truncated
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
@@ -7052,8 +7052,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
|
||||
evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
@@ -7065,7 +7065,7 @@ status = "shipped"
|
||||
commands = ["export.rtf"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out."
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-xlsx"
|
||||
@@ -8484,8 +8484,8 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id"]
|
||||
notes = "AccessKit labels on all custom widgets; full audit in M14.3"
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/it/viewer.rs::the_accessible_window_has_the_window_title_as_its_name"]
|
||||
notes = "AccessKit labels on all custom widgets; the accessible window is named with the window title and follows it (#4614); full audit in M14.3"
|
||||
|
||||
[[feature]]
|
||||
id = "a11y.keyboard-only"
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user