Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-on-focus

# Conflicts:
#	crates/engine/src/lib.rs
#	parity/acrobat-features.toml
This commit is contained in:
Eric Kaden committed 2026-10-25 10:57:17 -04:00
commit 470a89bda6
71 files changed
+5811 -340

No files matched your search

+2 -1
View File
@@ -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 |
+10
View File
@@ -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
View File
@@ -3934,7 +3934,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"chrono",
"craft-quit",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3972,7 +3972,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3983,7 +3983,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3994,7 +3994,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4016,7 +4016,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4034,11 +4034,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.13.0"
version = "0.14.0"
[[package]]
name = "pdfcraft-content"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4046,7 +4046,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4077,7 +4077,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"aes",
"md-5",
@@ -4089,7 +4089,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4104,7 +4104,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4145,14 +4145,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"flate2",
"proptest",
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4172,7 +4172,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4185,11 +4185,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.13.0"
version = "0.14.0"
[[package]]
name = "pdfcraft-js"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4197,7 +4197,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4210,14 +4210,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4227,7 +4227,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4242,11 +4242,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.13.0"
version = "0.14.0"
[[package]]
name = "pdfcraft-organize"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4267,7 +4267,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4278,7 +4278,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4306,7 +4306,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4325,7 +4325,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4361,11 +4361,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.13.0"
version = "0.14.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"eframe",
"egui",
@@ -4425,7 +4425,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4439,7 +4439,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
+34 -34
View File
@@ -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"
+1
View File
@@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
+4 -2
View File
@@ -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;
}
+6
View File
@@ -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);
+2 -2
View File
@@ -641,7 +641,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"],
@@ -970,7 +970,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(
+50 -4
View File
@@ -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,15 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
}
/// Hundreds of calculations run on opening and again after a change through the tools. The
/// time limits are raised so that a busy machine does not stop them (the engine's tests check
/// that the limits stop them).
#[test]
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() {
let dir = workdir("xfa-many");
let (sections, rows) = (10, 20);
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
let mut a = auto(&dir);
let mut a = auto(&dir).with_xfa_time_scale(100);
let started = std::time::Instant::now();
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
let open_took = started.elapsed();
@@ -7278,7 +7322,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 }));
@@ -7348,7 +7393,8 @@ fn xfa_focus_scripts_run_through_form_focus() {
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 }));
+50 -3
View File
@@ -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>> {
+14 -4
View File
@@ -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(
+9 -3
View File
@@ -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)]
+82
View File
@@ -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![
+12 -1
View File
@@ -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",
+83 -30
View File
@@ -2467,6 +2467,8 @@ struct EditCtx {
xfa_ran_away: bool,
/// An XFA enter or exit event whose scripts changed nothing: no edit to keep.
xfa_unchanged: 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()`.
@@ -2504,6 +2506,7 @@ impl EditCtx {
xfa_datasets: None,
xfa_ran_away: false,
xfa_unchanged: false,
xfa_time_scale: 1,
xfa_removed: Vec::new(),
pages: 0,
date: now.map(pdfcraft_cos::pdf_date),
@@ -2686,7 +2689,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 = if matches!(activity.as_str(), "enter" | "exit") {
xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty())
} else {
@@ -3088,16 +3092,24 @@ fn renderer_password(bytes: &[u8]) -> String {
}
}
/// The document as it may be written to a file (#3926): a copy without the values of text fields
/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231
/// (a note to the Password flag): "it is imperative that PDF processors never store the value of
/// the text field in the PDF file if this flag is set". The open document keeps them in memory.
/// The document as it may be written to a file (#3926, #3944): a copy without the values of text
/// fields with the Password flag (or XFA `<passwordEdit/>` fields in the datasets), or `cos`
/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag):
/// "it is imperative that PDF processors never store the value of the text field in the PDF file
/// if this flag is set". The open document keeps them in memory.
fn writable(cos: &pdfcraft_cos::Document) -> Result<std::borrow::Cow<'_, pdfcraft_cos::Document>, EditError> {
if !pdfcraft_forms::has_password_values(cos) {
let has_form_pw = pdfcraft_forms::has_password_values(cos);
let has_xfa_pw = pdfcraft_xfa::has_password_values(cos);
if !has_form_pw && !has_xfa_pw {
return Ok(std::borrow::Cow::Borrowed(cos));
}
let mut copy = cos.clone();
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
if has_form_pw {
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
}
if has_xfa_pw {
pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
}
Ok(std::borrow::Cow::Owned(copy))
}
@@ -3247,6 +3259,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.
@@ -3255,6 +3270,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.
@@ -3491,7 +3507,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;
@@ -3534,9 +3551,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
@@ -3554,13 +3576,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`).
@@ -3579,6 +3602,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
@@ -3651,7 +3685,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.
@@ -3780,7 +3814,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;
@@ -3965,6 +4005,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();
@@ -3974,6 +4015,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();
@@ -4086,7 +4128,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, &mut exited))
@@ -4560,11 +4608,15 @@ impl Session {
// An encrypted file needs the password it is protected with now (the owner
// password when known, so saving never downgrades this session's rights).
let keys = editor.keys.clone();
// The saved file carries no Password-field values (#3943); the open document keeps them.
// The saved file carries no Password-field values (#3943, #3944); the open document keeps them.
let typed = pdfcraft_forms::password_values(&editor.cos);
editor.cos =
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
if !typed.is_empty() && doc.info.xfa.is_some() {
let mut datasets = None;
let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets);
}
editor.saved = bytes;
editor.saved_keys = keys;
editor.unsaved_file = false;
@@ -5171,17 +5223,18 @@ impl Session {
let mut out = Vec::new();
for d in &mut self.docs {
if d.dirty && d.generation != d.snapshot_generation {
// The working bytes hold typed Password-field values (#3926): a recovery file is
// written from a copy without them, and none is written if that copy fails.
let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) {
Some(e) => match writable(&e.cos)
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
{
Ok(b) => Arc::new(b),
Err(_) => continue,
},
None => d.bytes.clone(),
};
// The working bytes hold typed Password-field values (#3926, #3944): a recovery file
// is written from a copy without them, and none is written if that copy fails.
let bytes =
match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) {
Some(e) => match writable(&e.cos)
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
{
Ok(b) => Arc::new(b),
Err(_) => continue,
},
None => d.bytes.clone(),
};
d.snapshot_generation = d.generation;
out.push(RecoverySnapshot {
doc: d.id,
+2 -1
View File
@@ -87,8 +87,9 @@ impl OptimizeJob {
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
let OptimizeJob { mut cos, settings, discard, opts } = self;
// Like every file written, without the values of Password-flag fields (#3926).
// Like every file written, without the values of Password-flag fields (#3926, #3944).
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
step(OptimizeStage::Discarding)?;
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
+188 -10
View File
@@ -2284,6 +2284,82 @@ fn a_signed_file_carries_no_password_field_value() {
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
}
/// #3944: a value typed into an XFA `<passwordEdit/>` field stays in the open document (both the
/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`),
/// but is written to no file — neither on the widget nor in the XFA datasets stream.
#[test]
fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() {
let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap();
let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
// Edit the password twice so an earlier edit's datasets stream could not linger in the overlay.
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap();
let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap();
assert!(field.has(pdfcraft_forms::flags::PASSWORD));
assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value");
assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets");
assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place");
let saved = s.save_bytes(id).unwrap();
for leak in ["hunter2", "secret1", "*******"] {
assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}");
assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}");
assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}");
}
let snaps = s.autosave_snapshots();
assert_eq!(snaps.len(), 1);
for leak in ["hunter2", "secret1", "*******"] {
assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}");
}
// Reopening the saved file in a new session leaves the password field empty (it does not
// repopulate from the XFA datasets on open).
let mut t = Session::new();
let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap();
assert!(
t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()),
"reopened XFA form must have an empty password field"
);
// Recording the save keeps the typed password in the open document (and its live datasets),
// while a subsequent save still omits it.
s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap();
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]);
assert!(!s.get(id).unwrap().dirty);
for leak in ["hunter2", "secret1", "*******"] {
assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}");
}
// Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is
// overwritten in place rather than left behind.
s.undo(id).unwrap();
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap();
let saved_after_undo = s.save_bytes(id).unwrap();
for leak in ["hunter2", "secret1", "secret3", "*******"] {
assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}");
}
// A relayout (`rerender`) after typing a password frees the replaced generated widget and its
// masked appearance stream so neither remains in the overlay when saving.
let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace(
r#"<subform name="page1" layout="tb">"#,
r#"<subform name="page1" layout="tb"><field name="grow" w="2in"><ui><textEdit multiLine="1"/></ui></field>"#,
);
let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap();
let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone();
s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap();
s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap();
let saved_relayout = s.save_bytes(id2).unwrap();
for leak in ["s3cr3t!", "*******"] {
assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}");
}
}
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
@@ -3574,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]
@@ -5388,11 +5468,11 @@ fn on_changes_notes_only_the_exits_that_ran() {
let names = vec!["probe".to_string()];
let mut exited = Vec::new();
let mut out = crate::js::JsOutput::default();
let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true };
let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true, time_scale: 1 };
crate::xfa::on_changes(&mut cos, &tpl, &names, &mut stopped, &mut exited).unwrap();
assert!(exited.is_empty(), "no exit ran: {exited:?}");
let mut out = crate::js::JsOutput::default();
let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false };
let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false, time_scale: 1 };
crate::xfa::on_changes(&mut cos, &tpl, &names, &mut running, &mut exited).unwrap();
assert_eq!(exited.len(), 1, "the exit ran: {exited:?}");
}
@@ -5674,6 +5754,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.
@@ -5757,6 +5905,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>";
@@ -5776,7 +5926,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
@@ -5934,7 +6085,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();
@@ -5963,16 +6115,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;
@@ -5985,9 +6140,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()]);
@@ -5995,6 +6151,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.
+13
View File
@@ -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() } })
})?
}
}
+9 -3
View File
@@ -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 {
@@ -911,7 +917,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(|_| ())
}
+2
View File
@@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?;
set_value(&mut doc, "agree", &FieldValue::Check(true))?;
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?;
set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?;
// Many values: as set_value one by one, but reading the form once and recalculating once.
let results: Vec<Result<(), FormError>> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?;
reset(&mut doc, None)?; // Clear form (back to /DV)
// Prepare a form
+12 -1
View File
@@ -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}");
}
}
+1 -1
View File
@@ -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)))
}
+1
View File
@@ -963,6 +963,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
rotation: 0,
caption: None,
list: None,
round: true,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g");
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
+114 -9
View File
@@ -118,6 +118,9 @@ pub struct Widget {
pub caption: Option<String>,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
/// (`/PCXfaRound true`).
pub round: bool,
}
/// A terminal form field.
@@ -570,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields {
Fields { items, adopted_page_fields }
}
#[cfg(test)]
thread_local! {
/// Whole-form readings ([`enumerate`]) on this thread.
static FORM_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread has read the whole form.
#[cfg(test)]
fn form_reads() -> usize {
FORM_READS.with(std::cell::Cell::get)
}
fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
#[cfg(test)]
FORM_READS.with(|n| n.set(n.get() + 1));
let af = acroform(doc);
let mut page_of = std::collections::HashMap::new();
let mut annot_index = std::collections::HashMap::new();
@@ -882,6 +899,7 @@ fn walk(
})
.flatten(),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
})
})
.collect();
@@ -931,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script
if all.is_empty() {
return Err(FormError::NoForm);
}
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let named: Vec<usize> = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect();
fill(doc, &all, &named, name, value, scripts)?;
recalculate_with(doc, scripts)?;
Ok(())
}
/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and
/// recalculating once, after the last value. Filling fields one by one reads the whole form twice
/// per field, which takes minutes on a form with thousands of fields.
///
/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after
/// which, as with [`set_value`], no value counts as filled (though each was written).
pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result<Vec<Result<(), FormError>>, FormError> {
let mut scripts = NoScripts;
let mut form = NamedFields::read(doc);
// The field objects this batch wrote since the form was last read.
let mut written = std::collections::HashSet::new();
let mut results = Vec::with_capacity(values.len());
let mut filled = false;
for (name, value) in values {
if form.all.is_empty() {
results.push(Err(FormError::NoForm));
continue;
}
// A field written earlier in the batch (named twice, or a twin's name) is read again,
// so this write starts from what the earlier one left, as it would one by one.
if form.objs(name).any(|o| written.contains(&o)) {
form = NamedFields::read(doc);
written.clear();
}
let named = form.named(name);
let result = fill(doc, &form.all, named, name, value, &mut scripts);
written.extend(form.objs(name));
filled |= result.is_ok();
results.push(result);
}
if filled {
recalculate_with(doc, &mut scripts)?;
}
Ok(results)
}
/// The form's fields, with the positions of each fully qualified name in them (in field order).
struct NamedFields {
all: Vec<Field>,
by_name: std::collections::HashMap<String, Vec<usize>>,
}
impl NamedFields {
fn read(doc: &Document) -> Self {
let all = fields(doc);
let mut by_name: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
for (i, f) in all.iter().enumerate() {
by_name.entry(f.name.clone()).or_default().push(i);
}
NamedFields { all, by_name }
}
fn named(&self, name: &str) -> &[usize] {
self.by_name.get(name).map_or(&[], Vec::as_slice)
}
/// The field objects named `name`.
fn objs<'a>(&'a self, name: &str) -> impl Iterator<Item = ObjRef> + 'a {
self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj)
}
}
/// Write `value` into the field `name`, without recalculating: `named` are the positions in
/// `all` (the form as read before this write) of the fields with that name, in field order.
fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?;
if f.read_only() {
return Err(FormError::ReadOnly(name.into()));
}
write_value(doc, f, value, scripts)?;
author::form_fonts_as_objects(doc, &all)?;
author::form_fonts_as_objects(doc, all)?;
// Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a
// name as separate field dictionaries, instead of widgets under one parent, get the value
// the first one took after its scripts, without running them again.
let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect();
let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect();
if !twins.is_empty() {
let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default();
for t in twins {
share_value(doc, f.obj, t, &taken, scripts)?;
}
}
recalculate_with(doc, scripts)?;
Ok(())
}
@@ -1253,6 +1341,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
[one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
_ => None,
};
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
let mut undrawn = false;
for w in &f.widgets {
let (stream, missing) = match &shown {
@@ -1260,7 +1349,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
None => appearance::field_appearance_drawn(doc, f, w, values, true),
};
undrawn |= missing.is_some();
let ap = doc.add(Object::Stream(stream));
let existing_ap = in_place
.then(|| {
doc.get(w.obj)
.as_dict()
.and_then(|d| d.get(b"AP").map(|a| doc.resolve(a)))
.and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()))
.and_then(|n| n.as_ref())
.filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_)))
})
.flatten();
let ap = match existing_ap {
Some(r) => {
doc.set(r, Object::Stream(stream));
r
}
None => doc.add(Object::Stream(stream)),
};
let mut fresh = Dict::new();
fresh.set(b"N".to_vec(), Object::Ref(ap));
let apd = merged_appearance(doc, w.obj, &fresh);
@@ -1315,7 +1420,7 @@ fn password_fields(doc: &Document) -> Vec<Field> {
/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save
/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like
/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be
/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep.
/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep.
fn has_password_flag(doc: &Document) -> bool {
let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0);
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
@@ -1323,7 +1428,7 @@ fn has_password_flag(doc: &Document) -> bool {
let mut seen = std::collections::HashSet::new();
while let Some((o, depth)) = stack.pop() {
let Object::Ref(r) = o else { continue };
if depth > 32 || !seen.insert(r) {
if depth > 64 || !seen.insert(r) {
continue;
}
if sets_it(r) {
@@ -1363,11 +1468,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)])
Ok(())
}
/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats.
/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats.
fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
let mut chain = vec![r];
let mut cur = r;
while chain.len() < 32 {
while chain.len() < 64 {
let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break };
if chain.contains(&parent) {
break;
+201
View File
@@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() {
}
}
/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag`
/// and `has_password_values`, matching the field tree walk limit of 64.
#[test]
fn a_password_field_nested_beyond_depth_32_is_recognized() {
let target_depth = 35;
let leaf_obj_num = 5 + target_depth;
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
];
for d in 0..target_depth {
let obj_num = 5 + d;
let next_obj_num = obj_num + 1;
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
}
objs.push(format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
leaf_obj_num - 1
));
let mut doc = document(&objs);
assert!(has_password_flag(&doc));
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
}
/// A password field whose widget is reachable through a page's Annots and whose inherited Password
/// flag is deeper than 32 levels is recognized by `has_password_flag`.
#[test]
fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() {
let target_depth = 35;
let leaf_obj_num = 5 + target_depth;
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"),
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
];
for d in 0..target_depth {
let obj_num = 5 + d;
let next_obj_num = obj_num + 1;
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
let ff_entry = if d == 0 { " /Ff 8192" } else { "" };
objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
}
objs.push(format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
leaf_obj_num - 1
));
let mut doc = document(&objs);
assert!(has_password_flag(&doc));
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
}
/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut
/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits.
#[test]
@@ -4300,3 +4359,145 @@ fn comb_fields_show_their_cells() {
let plain = saved_look(&doc, "d");
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
}
/// A form for filling many values at once: two summed fields and their calculated total, a
/// name two field dictionaries share, a check box, a radio group, a list and a read-only field.
fn batch_form() -> Document {
let tx = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>"
.into(),
tx("a", 700, "/V (1)"),
tx("b", 660, ""),
tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#),
tx("dup", 580, ""),
tx("dup", 540, ""),
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(),
"<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>"
.into(),
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>"
.into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>"
.into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
tx("locked", 500, "/Ff 1 /V (kept)"),
];
document(&objs)
}
/// What a filled form shows: every field as read, and each widget's drawn appearance.
fn filled(doc: &Document) -> (Vec<Field>, Vec<String>) {
let all = fields(doc);
let looks = all
.iter()
.flat_map(|f| &f.widgets)
.map(|w| {
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()));
match n.map(|n| doc.resolve(&n)) {
Some(n) => match &*n {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(),
other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(),
},
None => String::new(),
}
})
.collect();
(all, looks)
}
/// Filling values in one batch leaves the form as filling them one by one does: the values,
/// the twin of a shared name, the calculated total and every widget's appearance; a name given
/// twice ends with its last value, and is checked against the value it took earlier in the batch;
/// and each value fails as it would on its own.
#[test]
fn set_values_fills_as_set_value_does_one_by_one() {
let values: Vec<(String, FieldValue)> = vec![
("a".into(), FieldValue::Text("2".into())),
("b".into(), FieldValue::Text("3".into())),
("dup".into(), FieldValue::Text("shared".into())),
("agree".into(), FieldValue::Check(true)),
("size".into(), FieldValue::Radio(Some("L".into()))),
("country".into(), FieldValue::Choice(vec!["fr".into()])),
("nowhere".into(), FieldValue::Text("x".into())),
("locked".into(), FieldValue::Text("changed".into())),
("size".into(), FieldValue::Radio(Some("XL".into()))),
("dup".into(), FieldValue::Text("again".into())),
("a".into(), FieldValue::Text("10".into())),
// Refused only because the batch already selected L: the group keeps one selected.
("size".into(), FieldValue::Radio(None)),
];
let mut one_by_one = batch_form();
let expected: Vec<Result<(), FormError>> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect();
let mut batch = batch_form();
let results = set_values(&mut batch, &values).expect("recalculates");
assert_eq!(results, expected);
assert_eq!(
results.iter().filter(|r| r.is_err()).count(),
4,
"the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}"
);
let (all, looks) = filled(&batch);
assert_eq!((all.clone(), looks), filled(&one_by_one));
assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value");
assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value");
// And as saved.
assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one)));
}
/// A form without fields refuses each value, and an empty batch changes nothing.
#[test]
fn set_values_without_a_form_or_values() {
let mut doc = document(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
]);
let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap();
assert_eq!(r, vec![Err(FormError::NoForm)]);
let mut form = batch_form();
assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new());
assert!(!form.is_modified());
// Nothing filled, nothing recalculated: the total stays as the file has it.
let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap();
assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]);
assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified());
}
/// Filling many fields reads the form a fixed number of times, not once or twice per field
/// (which took minutes to open a form with thousands of fields whose XFA data was filled in
/// elsewhere).
#[test]
fn set_values_reads_the_form_once_not_per_value() {
let n: u32 = 60;
let mut objs: Vec<String> =
vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 5)).collect();
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")));
objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" ")));
for i in 0..n {
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10));
}
let mut doc = document(&objs);
let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect();
let before = form_reads();
let results = set_values(&mut doc, &values).unwrap();
let reads = form_reads() - before;
assert!(results.iter().all(Result::is_ok));
// One reading to fill, one to recalculate.
assert!(reads <= 2, "{reads} readings of the form for {n} values");
assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])]));
// A name filled again is read again, once: f0 and f1 again cost one more reading.
let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect();
let before = form_reads();
assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok));
let reads = form_reads() - before;
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
}
+7 -3
View File
@@ -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;
+8
View File
@@ -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");
+37 -8
View File
@@ -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
}
+9
View File
@@ -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()? {
+81 -2
View File
@@ -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:?}"
);
}
}
+9 -4
View File
@@ -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,
+1 -1
View File
@@ -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.
+91 -9
View File
@@ -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);
}
}
+16 -3
View File
@@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
+ owned(Hidden::Attachments)
}
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
Hidden::FormFields => {
let n = pdfcraft_forms::fields(doc).len();
if n > 0 {
n
} else if cat.contains(b"AcroForm") {
1
} else {
0
}
}
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
Hidden::HiddenLayers => {
let off = hidden_layers(doc).len();
@@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed_before = listed_annotations(doc);
let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0;
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm"));
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform);
if fields {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
@@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
cat.set(b"OCProperties".to_vec(), Object::Dict(oc));
}
}
Hidden::FormFields => {
cat.remove(b"AcroForm");
}
_ => {}
}
let n = count(h);
let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) };
if n > 0 {
done.push((h, n));
}
+21
View File
@@ -2708,6 +2708,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file()
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
}
/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no
/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save.
#[test]
fn removing_form_fields_removes_acroform_even_without_terminal_fields() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc =
one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]);
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap();
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap();
let counts = scan(&doc);
assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1));
assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
assert!(!doc.full_save_required());
let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap();
assert_eq!(done, [(Hidden::FormFields, 1)]);
assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
assert!(doc.full_save_required());
}
/// Removing hidden text cleans the structure tree as redaction does: the elements describing the
/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy.
#[test]
@@ -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]);
}
+1
View File
@@ -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;
+5 -7
View File
@@ -3413,8 +3413,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);
@@ -3860,20 +3859,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));
+93 -34
View File
@@ -118,6 +118,10 @@ pub(crate) struct MoveBackground {
key: Option<(u64, usize, usize)>,
renderer: Option<pdfcraft_render::RenderPool>,
texture: Option<egui::TextureHandle>,
/// The item alone on a transparent page: the pixels that follow the pointer. The page's own
/// raster has whatever the item covers in its box too, which must stay where it is (#4464).
item_renderer: Option<pdfcraft_render::RenderPool>,
item: Option<egui::TextureHandle>,
tag: Option<u64>,
/// The item's display-space box when the gesture started.
from: Option<[f64; 4]>,
@@ -130,7 +134,12 @@ pub(crate) struct MoveBackground {
impl MoveBackground {
pub fn pending(&self) -> bool {
self.renderer.is_some() && self.texture.is_none()
self.renderer.is_some() && self.item_renderer.is_some() && (self.texture.is_none() || self.item.is_none())
}
/// Both rasters are in: the page without the item, and the item alone.
fn ready(&self) -> bool {
self.texture.is_some() && self.item.is_some()
}
pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) {
@@ -145,6 +154,7 @@ impl MoveBackground {
*self = Self { key, ..Default::default() };
if let Some((_, page, index)) = key {
self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer());
self.item_renderer = doc.added_content_alone(page, index).ok().map(|p| p.renderer());
self.from = on_page(doc.added(), page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect());
self.picture = picture(page, index);
}
@@ -152,39 +162,60 @@ impl MoveBackground {
if self.settling.is_some() {
return;
}
let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return };
let (Some((_, page, _)), Some(renderer), Some(item_renderer)) = (self.key, self.renderer.as_ref(), self.item_renderer.as_ref()) else {
return;
};
let Some(p) = doc.info.pages.get(page) else { return };
let max_side = ctx.input(|i| i.max_texture_side) as f32;
let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0));
let tag = (scale * 1000.0) as u64;
if self.tag != Some(tag) {
renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
for r in [renderer, item_renderer] {
r.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
}
self.tag = Some(tag);
}
if let Some(out) = renderer.try_recv() {
if self.receive(ctx, tag) && self.pending() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Takes in the rasters rendered at `tag`. `false` when either failed: the preview is then
/// dropped whole, including rasters in from an earlier scale.
fn receive(&mut self, ctx: &egui::Context, tag: u64) -> bool {
let (Some(renderer), Some(item_renderer)) = (self.renderer.as_ref(), self.item_renderer.as_ref()) else {
return false;
};
let mut failed = false;
for (r, slot, name) in
[(renderer, &mut self.texture, "added-content-move-background"), (item_renderer, &mut self.item, "added-content-move-item")]
{
let Some(out) = r.try_recv() else { continue };
if out.error.is_some() {
// Without a background the item stays put while dragged, as before.
self.renderer = None;
return;
}
if out.request.tag == tag {
self.texture = Some(ctx.load_texture(
"added-content-move-background",
failed = true;
} else if out.request.tag == tag {
*slot = Some(ctx.load_texture(
name,
crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba),
egui::TextureOptions::LINEAR,
));
}
}
if self.pending() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
if failed {
// Without both rasters the item stays put while dragged, as before.
self.renderer = None;
self.item_renderer = None;
self.texture = None;
self.item = None;
}
!failed
}
/// The move was applied: keep the preview, now at the new generation, until the page is
/// rendered again. Any other edit ends it.
pub fn committed(&mut self, edit: &Edit, generation: u64) {
match edit {
Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
Edit::UpdateContent { page, index, content } if self.ready() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
self.key = Some((generation, *page, *index));
self.settling = Some(content.rect());
}
@@ -198,21 +229,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;
}
@@ -231,7 +252,7 @@ impl MoveBackground {
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
// A pixel around the box, so its edges come along.
let (r, to) = (r.expand(1.0), to.expand(1.0));
let mut mesh = egui::Mesh::with_texture(page_tex);
let mut mesh = egui::Mesh::with_texture(item.id());
for (a, b) in [
(r.left_top(), to.left_top()),
(r.right_top(), to.right_top()),
@@ -944,19 +965,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
}
});
ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted));
let mut crop = img.crop.map(|v| (v * 100.0).round());
let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0;
// Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file.
let shown = img.crop.map(|v| v * 100.0);
let mut crop = shown;
let mut changed = false;
ui.horizontal(|ui| {
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
ui.label(label);
// Applied when the drag ends, so a drag is one undo step.
let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%"));
// Up to what the opposite side leaves. Applied when the drag ends: one undo step.
let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0);
let Some(side) = crop.get_mut(k) else { continue };
let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%"));
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
}
});
if changed {
let mut i = img.clone();
i.crop = crop.map(|v| v / 100.0);
for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) {
if now != was {
*stored = now / 100.0;
}
}
if i.crop != img.crop {
out = Some(ImageAction::Update(AddedContent::Image(i)));
}
@@ -1024,6 +1054,35 @@ mod tests {
assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom");
}
/// #4464: both rasters were in at one scale and a render at a new scale failed. The rasters
/// from the old scale must go too, or the item keeps following the pointer although the
/// preview gave up.
#[test]
fn failed_rerender_drops_the_rasters_from_the_earlier_scale() {
let ctx = egui::Context::default();
let texture = |name| Some(ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), egui::TextureOptions::LINEAR));
// Bytes no parser accepts: every render answers with an error.
let pool = || {
let pool = pdfcraft_render::RenderPool::new_inline(std::sync::Arc::new(b"not a pdf".to_vec()), Default::default());
pool.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag: 2000, ..Default::default() }]);
pool
};
let mut mb = MoveBackground {
key: Some((0, 0, 0)),
renderer: Some(pool()),
texture: texture("background"),
item_renderer: Some(pool()),
item: texture("item"),
tag: Some(2000),
from: Some([0.0, 0.0, 10.0, 10.0]),
..Default::default()
};
assert!(mb.ready(), "both rasters from the earlier scale are in");
assert!(!mb.receive(&ctx, 2000), "the new scale's renders fail");
assert!(mb.texture.is_none() && mb.item.is_none(), "no raster is left to draw the moving item from");
assert!(!mb.ready() && !mb.pending());
}
/// #4245: the page counts the spacing after a line's last character too, so a line that fits
/// only without it wraps in the saved text and must wrap in the editor.
#[test]
File diff suppressed because it is too large. Load diff
+39 -2
View File
@@ -159,7 +159,7 @@ fn plural_sl(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 34] = [
pub static LANGUAGES: [LangInfo; 35] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@@ -215,6 +215,10 @@ pub static LANGUAGES: [LangInfo; 34] = [
LangInfo {
code: "ckb", name: "کوردیی ناوەندی", source: include_str!("ckb.tsv"), plural: plural_one_other, rtl: true, catalog: OnceLock::new()
},
// Persian (Farsi); every `fa-*` locale (`fa-IR`, `fa_IR.UTF-8`, `fa-AF` ...) resolves here. Right
// to left like Arabic: see `LangInfo::rtl`. CLDR `fa` counts have two forms (one: i = 0 or n = 1,
// other), which for whole counts is the French rule, so `@plural` entries list two forms.
LangInfo { code: "fa", name: "فارسی", source: include_str!("fa.tsv"), plural: plural_fr, rtl: true, catalog: OnceLock::new() },
// Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here.
LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() },
// Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other).
@@ -1672,7 +1676,7 @@ mod tests {
assert_eq!(ar.name(), "العربية");
assert!(ar.rtl());
let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect();
assert_eq!(rtl, ["ar", "ckb"], "Arabic and Kurdish are the right-to-left languages");
assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages");
assert_eq!(normalize_pref("AR"), Some("ar"));
for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] {
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
@@ -1723,6 +1727,39 @@ mod tests {
assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]);
}
fn persian() -> Lang {
Lang::from_code("fa").expect("fa registered")
}
#[test]
fn persian_is_registered_and_persists() {
let fa = persian();
assert_eq!(fa.name(), "فارسی");
assert!(fa.rtl());
// CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural.
assert_eq!(fa.0.plural_forms(), 2);
assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]);
assert_eq!(normalize_pref("FA"), Some("fa"));
for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] {
assert_eq!(lang_from_tag(tag), Some(fa), "{tag}");
}
assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa));
// Lookups return Persian (single words in display order).
assert_eq!(tr(fa, "Save"), "ذخیره");
assert_eq!(tr(fa, "Cancel"), "لغو");
assert_eq!(tr(fa, "File"), "فایل");
// Unknown strings and user text are never reordered by a lookup.
assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "FA").unwrap();
assert_eq!(app.language, "fa");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "fa");
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, "fa");
}
/// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface
/// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps
/// it; Arabic labels must not need a Japanese face).
+3
View File
@@ -3672,6 +3672,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).
+13 -2
View File
@@ -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"];
+20 -7
View File
@@ -1,8 +1,9 @@
//! The pointer as the pages see it, read once per frame from the page view's content widget.
//!
//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the
//! chrome over a page scrolled under it) and keeps a press with the widget it began on until
//! release. egui's raw pointer state knows neither, and its hover position is gone on the frame
//! chrome over a page scrolled under it) and which widget holds a drag (a panel's tab carried
//! over them), and keeps a press with the widget it began on until release. egui's raw pointer
//! state knows none of that, and its hover position is gone on the frame
//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and
//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's
//! pointer positions or button states; a test below keeps it that way.
@@ -26,10 +27,11 @@ const MAX_EDGES: usize = 8;
/// One frame's pointer, as the pages see it.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct PagePointer {
/// Where the pointer is, while the pages are what egui has under it, or while they hold a
/// press (until the frame of its release, past a page's edge included). With a dialog open
/// only a drag the pages already have keeps it. It is egui's `interact_pos`, which still has
/// the position on the frame a touch lifts.
/// Where the pointer is, while the pages are what egui has under it and no other widget holds
/// a drag (a panel's tab carried over them), or while they hold a press (until the frame of
/// its release, past a page's edge included). With a dialog open only a drag the pages
/// already have keeps it. It is egui's `interact_pos`, which still has the position on the
/// frame a touch lifts.
pub pos: Option<Pos2>,
/// Where the press the pages hold began, also on the frame of its release when the press
/// came in that same frame (egui has forgotten it by then). `None` for a press that began on
@@ -59,7 +61,8 @@ impl PagePointer {
// pages (egui's interact radius), so the top layer there must be the pages' own. A
// dialog covers them from the frame it opens, before egui knows its layer.
let layer = ui.layer_id();
let on_top = |p: &Pos2| !modal && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
let on_top =
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
@@ -78,6 +81,16 @@ impl PagePointer {
}
}
/// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's
/// grip): egui hovers nothing else until it stops, though it still reports the pages under the
/// pointer. A widget on the pages (a form field's editor) keeps them.
fn dragged_elsewhere(ui: &egui::Ui, resp: &egui::Response) -> bool {
let ctx = ui.ctx();
ctx.dragged_id().is_some_and(|id| {
id != resp.id && !ctx.read_response(id).is_some_and(|r| r.layer_id == resp.layer_id && resp.interact_rect.contains_rect(r.interact_rect))
})
}
fn first_press(edges: &[Edge]) -> Option<Pos2> {
edges.iter().find_map(|e| match e {
Edge::Press(p) => Some(*p),
+79 -34
View File
@@ -913,42 +913,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();
}
});
}
}
+79 -10
View File
@@ -248,20 +248,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 });
}
@@ -389,4 +410,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");
}
}
+189
View File
@@ -0,0 +1,189 @@
//! 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);
// The PDF's menu: 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_all_by_label("Save attachment…").last().unwrap().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_all_by_label("Open in a new tab").last().unwrap().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_all_by_label("Delete attachment").last().unwrap().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()
}
+33
View File
@@ -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]
+1
View File
@@ -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;
+46
View File
@@ -339,6 +339,52 @@ fn link_tip_stays_off_while_the_palette_is_open() {
h.get_by_label("https://example.org/");
}
/// A panel's tab carried over a link shows neither its tip nor its cursor, and the link answers
/// again once the tab is let go.
#[test]
fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = open(link_pdf(), "150", false);
h.state_mut().set_option("panel", "comments").unwrap();
for _ in 0..4 {
h.run_steps(2);
}
let on_link = at(&h, 100.0, 340.0);
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand);
h.run_steps(2);
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
// Away from the link, then the Inspector's tab is picked up and carried over it.
h.hover_at(at(&h, 150.0, 100.0));
h.run_steps(4);
let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center();
h.hover_at(tab);
h.run_steps(1);
h.drag_at(tab);
h.run_steps(1);
for i in 1..=8 {
h.hover_at(tab + (on_link - tab) * (i as f32 / 8.0));
h.run_steps(1);
}
for _ in 0..4 {
h.run_steps(2);
assert!(h.ctx.dragged_id().is_some(), "the tab is still being dragged");
assert!(!tip_shown(&h), "no link tip under a dragged panel tab");
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
}
// Back to the tab and let go there, which leaves the layout as it was.
for i in 1..=8 {
h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0));
h.run_steps(1);
}
h.drop_at(tab);
h.run_steps(2);
assert!(h.ctx.dragged_id().is_none());
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "the link answers again");
h.run_steps(2);
assert!(tip_shown(&h), "and shows its tip");
}
fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) {
h.hover_at(egui::pos2(800.0, 500.0));
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move });
+46
View File
@@ -1468,6 +1468,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.
+23
View File
@@ -2087,3 +2087,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()));
}
+6 -2
View File
@@ -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
+211 -4
View File
@@ -12,7 +12,7 @@ use std::collections::HashMap;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use crate::XfaError;
use crate::model::{Field, Node, SomIndices, Template};
use crate::model::{Field, Node, SomIndices, Template, Ui};
use crate::packets::{Encoding, encode};
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
@@ -1630,11 +1630,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
}
/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream
/// `reuse`, else as a new object.
/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object.
fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option<ObjRef>, bytes: &[u8]) -> ObjRef {
let stream = Object::Stream(Stream::flate(Dict::new(), bytes));
match (old.and_then(Object::as_ref), reuse) {
(Some(r), Some(keep)) if r == keep => {
match old.and_then(Object::as_ref) {
Some(r) if reuse == Some(r) || doc.is_edited(r.num) => {
doc.set(r, stream);
r
}
@@ -1794,3 +1794,210 @@ pub fn is_on(text: &str, on: &str) -> bool {
}
!matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty()
}
/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or
/// the single `/XFA` stream), when `doc` has one.
fn datasets_stream_ref(doc: &Document) -> Option<ObjRef> {
let root = doc.root()?;
let catalog = doc.get(root);
let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?;
let xfa = acro.as_dict()?.get(b"XFA")?;
match &*doc.resolve(xfa) {
Object::Array(items) => {
let at = (0..items.len())
.step_by(2)
.find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?;
items.get(at + 1)?.as_ref()
}
Object::Stream(_) => xfa.as_ref(),
_ => None,
}
}
/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14,
/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm
/// dictionary.
fn acro_password_fields(doc: &Document) -> Vec<(String, String)> {
const PASSWORD_FLAG: i64 = 1 << 13;
let Some(root) = doc.root() else { return Vec::new() };
let catalog = doc.get(root);
let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() };
let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else {
return Vec::new();
};
struct AcroWalk<'a> {
doc: &'a Document,
seen: std::collections::HashSet<ObjRef>,
out: Vec<(String, String)>,
}
impl AcroWalk<'_> {
fn walk(
&mut self,
items: &[Object],
parent_name: &str,
inherited_ft: Option<&[u8]>,
inherited_ff: i64,
inherited_dv: Option<&str>,
depth: usize,
) {
if depth > 32 {
return;
}
for item in items {
let Some(r) = item.as_ref() else { continue };
if !self.seen.insert(r) {
continue;
}
let obj = self.doc.get(r);
let Some(d) = obj.as_dict() else { continue };
let ft = d.name(b"FT").or(inherited_ft);
let ff = d.int(b"Ff").unwrap_or(inherited_ff);
let dv_own = text_of(self.doc, d, b"DV");
let dv = dv_own.as_deref().or(inherited_dv);
let t = text_of(self.doc, d, b"T");
let full_name = match (&t, parent_name.is_empty()) {
(Some(seg), true) => seg.clone(),
(Some(seg), false) => format!("{parent_name}.{seg}"),
(None, _) => parent_name.to_string(),
};
let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T")));
if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 {
let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone());
if !som.is_empty() {
self.out.push((som, dv.unwrap_or_default().to_string()));
}
}
if !kids.is_empty() {
self.walk(&kids, &full_name, ft, ff, dv, depth + 1);
}
}
}
}
let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() };
w.walk(&top, "", None, 0, None, 0);
w.out
}
/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their
/// defaults (or empty when they have no default).
fn password_dataset_wants(doc: &Document) -> Vec<Want> {
if datasets_stream_ref(doc).is_none() {
return Vec::new();
}
let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() };
let Some(data) = parse_datasets(&packet) else { return Vec::new() };
with_template(doc, None, |tpl| {
let mut candidates: Vec<(String, DataPath, String)> = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(t) = tpl {
struct Walk<'a> {
tpl: &'a Template,
data: &'a DataNode,
nodes: usize,
seen: &'a mut std::collections::HashSet<DataPath>,
out: &'a mut Vec<(String, DataPath, String)>,
}
impl Walk<'_> {
fn child_som(parent: &str, name: Option<&str>, index: usize) -> String {
match name {
Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"),
Some(n) => format!("{n}[{index}]"),
None => parent.to_string(),
}
}
fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) {
if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES {
return;
}
let sibs = SomIndices::of(children);
for (i, n) in children.iter().enumerate() {
if self.nodes >= crate::script::MAX_FORM_NODES {
break;
}
self.nodes += 1;
let sib = sibs.at(i);
match n {
Node::Subform(sf) => {
let from_data = match &sf.common.name {
Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name),
_ => 0,
};
let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data);
for k in 0..instances {
if k > 0 {
if self.nodes >= crate::script::MAX_FORM_NODES {
break;
}
self.nodes += 1;
}
let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k);
self.walk(&sf.children, &som, depth + 1);
}
}
Node::Area(a) => {
let som = Self::child_som(parent_som, a.common.name.as_deref(), sib);
self.walk(&a.children, &som, depth + 1);
}
Node::Field(f) if f.ui == Ui::PasswordEdit => {
let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib);
let path = data_path(self.tpl, &som);
if !path.is_empty() && self.seen.insert(path.clone()) {
let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default();
self.out.push((som, path, default));
}
}
_ => {}
}
}
}
}
let root = Node::Subform(Box::new(t.root.clone()));
let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates };
w.walk(std::slice::from_ref(&root), "", 0);
if let Some(ps) = &t.root.page_set {
for (i, area) in ps.areas.iter().enumerate() {
let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
w.walk(&area.items, &som, 1);
}
}
}
for (som, default) in acro_password_fields(doc) {
let path = path_of(tpl, &som);
if !path.is_empty() && seen.insert(path.clone()) {
candidates.push((som, path, default));
}
}
candidates
.into_iter()
.filter_map(|(som, path, default)| {
let cur = data.value_at(&path)?;
(!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false })
})
.collect()
})
}
/// Whether any XFA password field (`<passwordEdit/>` or an AcroForm text field with the Password
/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944).
pub fn has_password_values(doc: &Document) -> bool {
!password_dataset_wants(doc).is_empty()
}
/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default
/// where the field has one, else emptying the element), replacing the datasets stream in place
/// (#3944). Returns how many password fields were cleared in the datasets.
pub fn clear_password_values(doc: &mut Document) -> Result<usize, XfaError> {
let wants = password_dataset_wants(doc);
if wants.is_empty() {
return Ok(0);
}
let count = wants.len();
let reuse = datasets_stream_ref(doc);
let report = rewrite(doc, &[Op::Values(&wants)], reuse)?;
if !report.written || !password_dataset_wants(doc).is_empty() {
let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str);
return Err(XfaError::Malformed(reason.to_string()));
}
Ok(count)
}
+37
View File
@@ -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> {
+4 -4
View File
@@ -92,14 +92,14 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize {
pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
pub use data::{
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets,
pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets,
write_datasets_reusing, write_datasets_with,
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path,
has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path,
write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with,
};
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,
+149 -11
View File
@@ -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| {
@@ -1203,6 +1323,7 @@ pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
for r in streams {
doc.free(r);
}
doc.require_full_save();
Ok(out)
}
@@ -1293,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));
}
}
+48 -6
View File
@@ -610,19 +610,61 @@ 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.
// 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 kept: Vec<Object> =
fields.into_iter().filter(|f| f.as_ref().is_none_or(|r| doc.get(r).as_dict().is_none_or(|d| !d.contains(crate::pdf::SOM_KEY)))).collect();
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)
&& doc.is_edited(n.num)
{
doc.free(n);
}
};
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);
}
}
}
let written = crate::pdf::write_form(doc, &form)?;
let mut warnings = form.warnings;
warnings.extend(written.warnings);
if written.usage_rights {
+294 -1
View File
@@ -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"[..]));
@@ -3350,6 +3566,7 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() {
ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new()));
doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap();
let r = strip(&mut doc).unwrap();
assert!(doc.full_save_required());
assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]);
assert_eq!(r.unbound_data, Some(0));
assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}");
@@ -3435,6 +3652,16 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
}
#[test]
fn strip_marks_document_as_requiring_full_save() {
use crate::pdf::strip;
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
render_into(&mut doc).unwrap();
assert!(!doc.full_save_required());
strip(&mut doc).unwrap();
assert!(doc.full_save_required(), "stripping XFA packets must require a full save to prevent data exposure in incremental saves");
}
#[test]
fn som_indices_count_earlier_same_named_siblings() {
let draw = |name: Option<&str>| {
@@ -3803,3 +4030,69 @@ fn faces_measure_text_at_their_standard_14_widths() {
let condensed = text::Face { h_scale: 0.8, ..face(text::Family::Times, true, false) };
assert!((condensed.width(sample) - 0.8 * Std14::TimesBold.text_width(sample, 10.0)).abs() < 1e-9);
}
/// #3944: clearing XFA password values removes values typed into `<passwordEdit/>` fields from the
/// datasets packet (including across repeating subforms), restores a field's template default
/// `<value><text>…</text></value>` when it has one, and replaces the datasets stream in place so
/// the typed password is not left in an unreferenced stream in the overlay.
#[test]
fn clear_password_values_removes_typed_passwords_from_datasets_in_place() {
let xdp = r#"<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>
<subform name="page1" layout="tb">
<field name="user" w="2in" h="0.3in"><ui><textEdit/></ui></field>
<field name="pin" w="2in" h="0.3in"><ui><passwordEdit/></ui></field>
<field name="preset" w="2in" h="0.3in"><ui><passwordEdit/></ui><value><text>0000</text></value></field>
<subform name="row" layout="lr-tb"><occur min="1" max="-1"/>
<field name="secret" w="2in" h="0.3in"><ui><passwordEdit/></ui></field>
</subform>
</subform>
</subform>
</template>
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
<xfa:data><form><page1><user>Ada</user><pin>hunter2</pin><preset>9999</preset><row><secret>first</secret></row><row><secret>second</secret></row></page1></form></xfa:data>
</xfa:datasets>
</xdp:xdp>"#;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
render_into(&mut doc).unwrap();
assert!(has_password_values(&doc));
let cleared = clear_password_values(&mut doc).unwrap();
assert_eq!(cleared, 4);
assert!(!has_password_values(&doc), "only default and non-password values remain");
assert_eq!(clear_password_values(&mut doc).unwrap(), 0);
let packets = read_packets(&doc).unwrap().unwrap();
assert!(packets.xdp.contains("<user>Ada</user>"), "non-password fields stay: {}", packets.xdp);
assert!(packets.xdp.contains("<preset>0000</preset>"), "template default restored: {}", packets.xdp);
for secret in ["hunter2", "9999", "first", "second"] {
assert!(!packets.xdp.contains(secret), "{secret} still in datasets: {}", packets.xdp);
}
let written = write_incremental(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(written)).unwrap();
for num in reopened.object_numbers() {
if let Object::Stream(st) = &*reopened.get(pdfcraft_cos::ObjRef::new(num, 0)) {
let decoded = st.decoded().unwrap_or_default();
let text = String::from_utf8_lossy(&decoded);
for secret in ["hunter2", "9999", "first", "second"] {
assert!(!text.contains(secret), "object {num} still holds {secret}: {text}");
}
}
}
}
/// #3944: a static XFA form whose AcroForm field sets the Password flag (`/Ff 8192`) also has its
/// typed value cleared from the datasets packet.
#[test]
fn clear_password_values_clears_static_xfa_password_fields() {
let pdf = String::from_utf8(static_shell("<form1><page1><name>hunter2</name></page1></form1>"))
.unwrap()
.replace("/T (name[0])", "/T (name[0]) /Ff 8192");
let mut doc = Document::open(Arc::new(pdf.into_bytes())).unwrap();
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
let packets = read_packets(&doc).unwrap().unwrap();
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp);
}
+33 -14
View File
@@ -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);
}
+81
View File
@@ -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);
}
+3 -1
View File
@@ -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
+3 -1
View File
@@ -79,8 +79,9 @@ help/docs polish (catalog and script work are already done), not missing UI stri
| 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 |
| Persian | `fa` | 2,776 entries (every bundled key) | 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).
**20 other languages** ship (34 catalogs plus English = **35** 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.
Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat.
@@ -88,6 +89,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Added Persian (`fa`, 2,776 entries, every bundled key); right-to-left like Arabic and Kurdish with the same layout limits; 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 |
+5 -3
View File
@@ -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,7 +12,7 @@ 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).
@@ -57,6 +57,8 @@ These need bidirectional support in the text layout itself, not in the catalog.
Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. 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 **فارسی**) covers the current 2,776-key union, 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`, `bidi::display_rtl` at load; entries are written in logical order here) and has the same four limits above. 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 face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above).
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). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
+1
View File
@@ -252,6 +252,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. |
+15 -15
View File
@@ -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"
@@ -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", "form_focus"]
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), enter and exit each time a field gains or loses focus, changed or not (through form_focus and the app; a radio button runs its own; an exit its change already ran is not run again, and scripts that change nothing leave no edit; exit as measured on a synthetic form in another viewer, enter from the spec and not measured), 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 chaLine 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), enter and exit each time a field gains or loses focus, changed or not (through form_focus and the app; a radio button runs its own; an exit its change already ran is not run again, and scripts that change nothing leave no edit; exit as measured on a synthetic form in another viewer, enter from the spec and not measured), 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,Line truncated
[[feature]]
id = "form.xfa-formcalc"
@@ -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"
+1 -1
View File
@@ -17,7 +17,7 @@ Other resource categories retain their existing parent-chain lookup.
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of thLine truncated
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) |
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
+52
View File
@@ -391,6 +391,13 @@ impl ColorSpace {
matches!(self.0.as_ref(), ColorSpaceType::DeviceGray)
}
// PdfCraft patch (63).
/// Whether this is `/DeviceCMYK` itself, not an ICC-based or default colour space that
/// stands in for it.
pub(crate) fn is_device_cmyk(&self) -> bool {
matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk)
}
/// Get the number of components of the color space.
pub(crate) fn num_components(&self) -> u8 {
match self.0.as_ref() {
@@ -1342,6 +1349,18 @@ fn f32_to_u8(val: f32) -> u8 {
(val * 255.0 + 0.5) as u8
}
// PdfCraft patch (63).
/// The `/DeviceCMYK` to `/DeviceGray` conversion of ISO 32000-2 §10.4.2.3,
/// `gray = 1.0 - min(1.0, 0.3 c + 0.59 m + 0.11 y + k)`, which §11.5.3 also gives for the
/// luminosity of a soft mask whose group is in `/DeviceCMYK`. A missing component counts as
/// zero, and each component is clamped to `[0, 1]` first (`NaN` and infinities read as zero).
pub(crate) fn device_cmyk_to_gray(cmyk: &[f32]) -> f32 {
let component = |i: usize| clamp_component(cmyk.get(i).copied().unwrap_or(0.0), 0.0, 1.0);
let ink = 0.3 * component(0) + 0.59 * component(1) + 0.11 * component(2) + component(3);
// Every component is in [0, 1], so `ink` is not negative and the result is in [0, 1].
1.0 - ink.min(1.0)
}
/// Clamp an input-derived colour component to `[lo, hi]` without panicking on
/// NaN or on a reversed range. Non-finite values map to zero.
fn clamp_component(value: f32, lo: f32, hi: f32) -> f32 {
@@ -1450,3 +1469,36 @@ pub(crate) trait ToRgb {
))
}
}
#[cfg(test)]
mod device_cmyk_gray_tests {
use super::device_cmyk_to_gray;
fn assert_close(cmyk: &[f32], expected: f32) {
let got = device_cmyk_to_gray(cmyk);
assert!(
(got - expected).abs() < 1e-5,
"{cmyk:?}: gray {got}, expected {expected}"
);
}
#[test]
fn each_ink_darkens_by_its_iso_weight() {
assert_close(&[0.0, 0.0, 0.0, 0.0], 1.0);
assert_close(&[0.0, 0.0, 0.0, 1.0], 0.0);
assert_close(&[0.0, 0.0, 0.0, 0.5], 0.5);
assert_close(&[1.0, 0.0, 0.0, 0.0], 0.7);
assert_close(&[0.0, 1.0, 0.0, 0.0], 0.41);
assert_close(&[0.0, 0.0, 1.0, 0.0], 0.89);
assert_close(&[0.5, 0.5, 0.5, 0.25], 0.25);
}
#[test]
fn ink_past_full_coverage_is_black_and_bad_components_read_as_zero() {
assert_close(&[1.0, 1.0, 1.0, 1.0], 0.0);
assert_close(&[0.0, 0.0, 0.0, 1.5], 0.0);
assert_close(&[2.0, -1.0, f32::NAN, f32::INFINITY], 0.7);
assert_close(&[], 1.0);
assert_close(&[0.0, 0.0], 1.0);
}
}
+6
View File
@@ -173,6 +173,11 @@ pub struct Context<'a> {
/// a limit was reached, the clip count to return to at its `Q`.
saved_bytes: usize,
skipped_saves: Vec<usize>,
// PdfCraft patch (63).
/// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`,
/// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3,
/// §10.4.2.3) rather than through the CMYK profile used for display.
pub(crate) luminosity_in_device_cmyk: bool,
}
/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash
@@ -251,6 +256,7 @@ impl<'a> Context<'a> {
nesting_depth,
saved_bytes: 0,
skipped_saves: Vec::new(),
luminosity_in_device_cmyk: false,
}
}
+8 -2
View File
@@ -1,4 +1,4 @@
use crate::color::{Color, ColorSpace};
use crate::color::{Color, ColorSpace, device_cmyk_to_gray};
use crate::context::{Context, path_as_rect};
use crate::device::Device;
use crate::util::{BezPathExt, Float32Ext};
@@ -169,7 +169,13 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a>
Paint::Color(Color::new(ColorSpace::device_gray(), smallvec![0.0], 0.0))
}
} else {
let color = Color::new(data.color_space, data.color, data.alpha);
// PdfCraft patch (63): see `Context::luminosity_in_device_cmyk`.
let color = if context.luminosity_in_device_cmyk && data.color_space.is_device_cmyk() {
let gray = device_cmyk_to_gray(&data.color);
Color::new(ColorSpace::device_gray(), smallvec![gray], data.alpha)
} else {
Color::new(data.color_space, data.color, data.alpha)
};
if let Some(tf) = &data.transfer_function {
Paint::Color(Color::from_rgba(tf.apply(&color.to_rgba())))
+28 -4
View File
@@ -1,4 +1,4 @@
use crate::color::{Color, ColorComponents, ColorSpace};
use crate::color::{Color, ColorComponents, ColorSpace, DeviceFamily, device_cmyk_to_gray};
use crate::context::{Context, InterpreterCache};
use crate::device::Device;
use crate::function::Function;
@@ -59,6 +59,9 @@ struct Repr<'a> {
transfer_function: Option<TransferFunction>,
settings: InterpreterSettings,
background: Color,
// PdfCraft patch (63).
/// A luminosity mask whose group colour space is `/DeviceCMYK`.
luminosity_in_device_cmyk: bool,
xref: &'a XRef,
nesting_depth: u32,
}
@@ -107,10 +110,17 @@ impl<'a> SoftMask<'a> {
// PdfCraft patch: the group's /CS is optional (ISO 32000-2 §11.6.5.2; Chrome/Skia omit
// it). Upstream returned None here, silently dropping the whole soft mask. Fall back to
// DeviceRGB, the blending space our renderer composites in.
let cs = group
let cs_object = group
.dict
.get::<Dict<'_>>(GROUP)
.and_then(|g| g.get::<Object<'_>>(CS))
.and_then(|g| g.get::<Object<'_>>(CS));
// PdfCraft patch (63): the group is composited in `/DeviceCMYK` itself. An ICC-based
// or calibrated group space keeps its own conversion.
let group_in_device_cmyk = cs_object
.as_ref()
.and_then(DeviceFamily::of_object)
.is_some_and(|family| family == DeviceFamily::Cmyk);
let cs = cs_object
.and_then(|o| ColorSpace::new(o, &context.interpreter_cache.object_cache))
.unwrap_or_else(ColorSpace::device_rgb);
let transfer_function = dict
@@ -121,7 +131,19 @@ impl<'a> SoftMask<'a> {
LUMINOSITY => {
let color = dict
.get::<ColorComponents>(BC)
.map(|c| Color::new(cs, c, 1.0))
.map(|c| {
// PdfCraft patch (63): a DeviceCMYK backdrop's luminosity is its gray
// (ISO 32000-2 §11.5.3), as for the group's content.
if group_in_device_cmyk && c.len() == 4 {
Color::new(
ColorSpace::device_gray(),
smallvec![device_cmyk_to_gray(&c)],
1.0,
)
} else {
Color::new(cs, c, 1.0)
}
})
.unwrap_or(Color::new(ColorSpace::device_gray(), smallvec![0.0], 1.0));
(MaskType::Luminosity, color)
@@ -157,6 +179,7 @@ impl<'a> SoftMask<'a> {
settings: context.settings.clone(),
xref: context.xref,
background,
luminosity_in_device_cmyk: group_in_device_cmyk && mask_type == MaskType::Luminosity,
parent_resources,
nesting_depth,
})))
@@ -174,6 +197,7 @@ impl<'a> SoftMask<'a> {
state,
self.0.nesting_depth,
);
ctx.luminosity_in_device_cmyk = self.0.luminosity_in_device_cmyk;
draw_form_xobject(&self.0.parent_resources, &self.0.group, &mut ctx, device);
}