mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
Merge remote-tracking branch 'upstream/main' into fix/xfa-required-fields
# Conflicts: # parity/acrobat-features.toml
This commit is contained in:
Generated
+45
-45
@@ -1178,17 +1178,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1211,7 +1211,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1221,7 +1221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1247,7 +1247,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1258,7 +1258,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1279,7 +1279,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -4205,7 +4205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4229,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4241,7 +4241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4263,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4285,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4303,11 +4303,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4315,7 +4315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4330,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4346,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4358,7 +4358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4373,7 +4373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4414,14 +4414,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4441,7 +4441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4454,11 +4454,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4466,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4479,14 +4479,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4497,7 +4497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4512,11 +4512,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4527,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4537,7 +4537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4548,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4560,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4576,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4595,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4631,11 +4631,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4687,7 +4687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4699,7 +4699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4713,7 +4713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+41
-41
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,51 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] }
|
||||
# internal
|
||||
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 }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.15.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.15.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.15.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.15.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.15.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.15.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.15.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.15.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.15.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.15.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.15.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.15.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.15.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.15.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.15.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.15.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.15.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.15.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.15.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.15.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.15.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -243,4 +243,40 @@ mod page_argument_tests {
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_clips_glyphs_outside_the_crop_box() {
|
||||
let dir = PageDir::new();
|
||||
let path = dir.0.join("cropped.pdf");
|
||||
let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(&path, pdf).unwrap();
|
||||
|
||||
for args in [vec![], vec!["--page", "1"]] {
|
||||
let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {out:?}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
|
||||
/// icon and buttons.
|
||||
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `js_run`: run `script` in the document, or as button `field`'s script, and report its
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
|
||||
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1135,7 +1135,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
|
||||
@@ -6513,6 +6513,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console
|
||||
/// lines, every message with its details, and requests.
|
||||
#[test]
|
||||
fn javascript_through_tools() {
|
||||
let dir = workdir("js");
|
||||
@@ -6546,10 +6548,17 @@ fn javascript_through_tools() {
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }),
|
||||
);
|
||||
assert_eq!(r["console"][0], "2500");
|
||||
assert_eq!(r["alerts"][0], "ok");
|
||||
// Every message, in order, with its title, icon and buttons.
|
||||
assert_eq!(
|
||||
r["alerts"],
|
||||
json!([
|
||||
{ "message": "ok", "title": null, "icon": "error", "buttons": "ok" },
|
||||
{ "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" },
|
||||
])
|
||||
);
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||||
@@ -7470,6 +7479,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
check(&mut a, doc);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
|
||||
/// and `js_run` reports their messages.
|
||||
#[test]
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
@@ -7498,7 +7509,7 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert!(field(&mut a, "amount_2").is_none());
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
|
||||
assert_eq!(r["alerts"], json!(["Hello 4"]));
|
||||
assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7511,7 +7522,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Console message"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
ok(&mut a, "doc_close", json!({"doc":doc}));
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
@@ -7521,7 +7532,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
assert_eq!(out["alerts"], json!([]));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Hello 2"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "xfa_to_static", json!({"doc":doc}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
|
||||
assert_eq!(out["alerts"].as_array().unwrap().len(), 1);
|
||||
@@ -7945,7 +7956,7 @@ fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)";
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"});
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 190, 40], "size": 7, "align": "right"});
|
||||
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
|
||||
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9"));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
|
||||
|
||||
@@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use crate::{EditError, check, n, page_list, place_tagged};
|
||||
|
||||
const HIDDEN: i64 = 2;
|
||||
const NO_ROTATE: i64 = 16;
|
||||
const NO_VIEW: i64 = 32;
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
|
||||
@@ -74,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
|
||||
Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy])
|
||||
}
|
||||
|
||||
/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of
|
||||
/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its
|
||||
/// upper-left corner where it was, which is how MuPDF and Poppler draw it.
|
||||
fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] {
|
||||
let (sin, cos) = match degrees.rem_euclid(360) {
|
||||
90 => (1.0, 0.0),
|
||||
180 => (0.0, -1.0),
|
||||
270 => (-1.0, 0.0),
|
||||
_ => return m,
|
||||
};
|
||||
let (px, py) = (rect[0], rect[3]);
|
||||
let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos];
|
||||
[
|
||||
m[0] * t[0] + m[1] * t[2],
|
||||
m[0] * t[1] + m[1] * t[3],
|
||||
m[2] * t[0] + m[3] * t[2],
|
||||
m[2] * t[1] + m[3] * t[3],
|
||||
m[4] * t[0] + m[5] * t[2] + t[4],
|
||||
m[4] * t[1] + m[5] * t[3] + t[5],
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Which {
|
||||
/// Every comment and/or every form field.
|
||||
@@ -194,6 +217,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let mut gone_inline: HashSet<usize> = HashSet::new();
|
||||
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
|
||||
let mut undrawn: HashSet<ObjRef> = HashSet::new();
|
||||
let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0);
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
@@ -230,6 +254,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(m) = placement(doc, &form, rect) {
|
||||
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
|
||||
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
|
||||
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
|
||||
@@ -507,6 +507,42 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
|
||||
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
|
||||
}
|
||||
|
||||
/// #2368: a widget with /NoRotate on a rotated page is shown upright, turned with the page's
|
||||
/// /Rotate about its rectangle's upper-left corner (§12.5.3, as MuPDF and Poppler draw it).
|
||||
/// Flattening drew its appearance at the rectangle as it is, so the widget turned sideways and
|
||||
/// moved; a neighbour without the flag is unchanged, and so is everything on an unrotated page.
|
||||
#[test]
|
||||
fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
|
||||
let page = stream("", " ");
|
||||
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 15 >>\nstream\n0 0 100 50 re f\nendstream";
|
||||
for (rotate, plain, upright) in [
|
||||
(0, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 1 0 0 1 100 500 cm /PCFl1 Do Q"),
|
||||
(90, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 1 -1 0 150 550 cm /PCFl1 Do Q"),
|
||||
(180, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q -1 0 0 -1 100 600 cm /PCFl1 Do Q"),
|
||||
(270, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 -1 1 0 50 550 cm /PCFl1 Do Q"),
|
||||
] {
|
||||
let widget =
|
||||
|flags: i64| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{flags}) /Rect [100 500 200 550] /F {flags} /AP << /N 7 0 R >> >>");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>"
|
||||
),
|
||||
&widget(4),
|
||||
&widget(4 | 16),
|
||||
&page,
|
||||
form,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2);
|
||||
let doc = reopen(&doc);
|
||||
let s = streams(&doc, 0);
|
||||
let flat = s.last().unwrap();
|
||||
assert!(flat.contains(plain), "/Rotate {rotate}: {flat}");
|
||||
assert!(flat.contains(upright), "/Rotate {rotate}: {flat}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
|
||||
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
|
||||
#[test]
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
|
||||
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
/// What scripts produced for the viewer since it last looked.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct JsOutput {
|
||||
pub alerts: Vec<String>,
|
||||
/// The messages scripts showed, in order, with their titles, icons and buttons.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
pub requests: Vec<Request>,
|
||||
/// Script errors (as Acrobat's console shows them).
|
||||
@@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
impl Scripts for JsRunner {
|
||||
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
|
||||
/// asked for; a refusal carries its last message.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
||||
let states: Vec<_> = fields
|
||||
.iter()
|
||||
@@ -218,7 +221,7 @@ impl Scripts for JsRunner {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
changes: changes(&states, &o.changed),
|
||||
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-77
@@ -4808,6 +4808,8 @@ fn scripted_form_with(helpers: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// AcroForm field scripts validate, calculate and format through edits, and a button script's
|
||||
/// changes, messages and requests come back.
|
||||
#[test]
|
||||
fn form_javascript_validates_calculates_and_formats() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -4826,7 +4828,7 @@ fn form_javascript_validates_calculates_and_formats() {
|
||||
// A button script changes fields in one undoable step, and asks for a page.
|
||||
let o =
|
||||
s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
|
||||
assert_eq!(o.alerts, ["Done"]);
|
||||
assert_eq!(o.alerts, [crate::js::Message::plain("Done")]);
|
||||
assert_eq!(o.requests, [js::Request::Print]);
|
||||
assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
|
||||
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
|
||||
@@ -4890,17 +4892,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() {
|
||||
assert!(s.take_js_output(id).alerts.is_empty());
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]);
|
||||
let out = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Hello 2"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Hello 2")]);
|
||||
assert_eq!(out.requests, [js::Request::Print]);
|
||||
// The same context rule applies to format/validate/calculate through JsRunner.
|
||||
s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
|
||||
assert_eq!(s.take_js_output(id).alerts, ["Field message"]);
|
||||
assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]);
|
||||
// Saved XFA keeps its container scripts for inspection, but does not execute them.
|
||||
let saved = s.get(id).unwrap().bytes.clone();
|
||||
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
|
||||
@@ -4925,7 +4927,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts(
|
||||
s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap();
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4942,7 +4944,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default());
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("6"));
|
||||
assert_eq!(out.alerts, ["PDF message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("PDF message")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6250,6 +6252,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows,
|
||||
/// with undo and save/reopen.
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
@@ -6274,7 +6278,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// A failing validate script shows its message; the value stays, as in Acrobat.
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
|
||||
// A button adds a row: new fields, same page, one undo step.
|
||||
let before = names(&s).len();
|
||||
@@ -6289,7 +6293,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Message boxes reach the viewer.
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
// The check box shows the hidden subform; undo hides it again.
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
@@ -6304,7 +6308,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// FormCalc scripts run too.
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec!["FormCalc".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]);
|
||||
// Saved and reopened: the visible subform, the values and the rows are what they were.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let id2 = s.open("again.pdf", None, saved, None).unwrap();
|
||||
@@ -6525,6 +6529,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
|
||||
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does.
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
|
||||
@@ -6543,7 +6548,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "qty"), vec!["500".to_string()]);
|
||||
// Buttons: a row is added, summed and removed; the message box reaches the viewer.
|
||||
let before = names(&s).len();
|
||||
@@ -6554,7 +6559,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
s.run_javascript(id, "", Some("removeRow")).unwrap();
|
||||
@@ -6689,6 +6694,8 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links,
|
||||
/// and noted.
|
||||
#[test]
|
||||
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
|
||||
@@ -6720,6 +6727,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
|
||||
}
|
||||
|
||||
/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from
|
||||
/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every
|
||||
/// one of them and in order.
|
||||
#[test]
|
||||
fn xfa_message_boxes_keep_their_title_icon_and_buttons() {
|
||||
use crate::js::{Message, MessageButtons, MessageIcon};
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
|
||||
r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#,
|
||||
)
|
||||
.replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("boxes.pdf", None, bytes, None).expect("opens");
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons };
|
||||
assert_eq!(
|
||||
o.alerts,
|
||||
vec![
|
||||
titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo),
|
||||
titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok),
|
||||
]
|
||||
);
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
|
||||
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
|
||||
@@ -8737,69 +8774,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted.
|
||||
fn console(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
for l in lines {
|
||||
if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT {
|
||||
@@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(&mut self, m: String) {
|
||||
/// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted.
|
||||
fn alert(&mut self, m: crate::js::Message) {
|
||||
if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT {
|
||||
self.run.out.alerts.push(m);
|
||||
} else {
|
||||
@@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
|
||||
for ev in r.events_of(&som, "validate") {
|
||||
let Some(o) = r.run(&ev, &value)? else { continue };
|
||||
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
|
||||
r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")));
|
||||
r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Limits;
|
||||
use crate::xfa::{
|
||||
HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances,
|
||||
reset_names, resolve_som, run_within,
|
||||
};
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
mod access;
|
||||
mod ast;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::ast::{Expr, Stmt};
|
||||
use super::value::{Val, is_numeric_text, num_text};
|
||||
use super::{
|
||||
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip,
|
||||
descendants_named, instances, reset_names, resolve_som,
|
||||
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, Message, MessageButtons, MessageIcon, R, Rc,
|
||||
TOO_DEEP, XfaEffect, child_named, clip, descendants_named, instances, reset_names, resolve_som,
|
||||
};
|
||||
|
||||
impl Interp<'_> {
|
||||
@@ -237,13 +237,21 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Call `method` on `base` (`$host`, a form object, an instance manager…) with `args`.
|
||||
fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R<Val> {
|
||||
let m = method.to_ascii_lowercase();
|
||||
match base {
|
||||
Val::Host => match m.as_str() {
|
||||
"messagebox" => {
|
||||
let t = self.arg_str(args, 0)?;
|
||||
self.h.push(XfaEffect::MessageBox(t));
|
||||
// `$host.messageBox(message, title, icon, buttons)`; a missing number is 0,
|
||||
// the default icon and buttons.
|
||||
let text = self.arg_str(args, 0)?;
|
||||
let title = Some(self.arg_str(args, 1)?).filter(|t| !t.is_empty());
|
||||
let icon = MessageIcon::from_code(Some(self.arg_num(args, 2)?));
|
||||
let buttons = MessageButtons::from_code(Some(self.arg_num(args, 3)?));
|
||||
self.h.push(XfaEffect::MessageBox(Message { text, title, icon, buttons }));
|
||||
// Always OK for now: the viewer can't ask the user and run the script on
|
||||
// with the answer yet (#4291).
|
||||
Ok(Val::Num(1.0))
|
||||
}
|
||||
"resetdata" => {
|
||||
|
||||
@@ -2,27 +2,85 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie
|
||||
use super::format::{printd, printf, printx, scand};
|
||||
use super::host::{arg, function, host, s, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
use super::{
|
||||
Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
js_string,
|
||||
};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A message argument: a string, or an object with `cMsg`, cut to a message's length.
|
||||
fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_array() && !o.is_callable() => {
|
||||
match named_params(v, key, ctx)? {
|
||||
Some(o) => {
|
||||
let m = o.get(JsString::from(key), ctx)?;
|
||||
text_line(&m, ctx)
|
||||
}
|
||||
_ => text_line(v, ctx),
|
||||
None => text_line(v, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and
|
||||
/// returns 1 (the OK button).
|
||||
/// `v` as a method's single object of named parameters: an object (not an array or function)
|
||||
/// that has `key`, the parameter every such call names. Any other value, a `new String(…)` or a
|
||||
/// caught `Error` included, is the first positional argument, read as text.
|
||||
fn named_params(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<Option<JsObject>> {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_array() && !o.is_callable() && o.has_property(JsString::from(key), ctx)? => Ok(Some(o.clone())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A message's title: `v` as text cut to a message's length, `None` when it is missing or empty.
|
||||
fn title(v: &JsValue, ctx: &mut Context) -> JsResult<Option<String>> {
|
||||
Ok(Some(text_line(v, ctx)?).filter(|t| !t.is_empty()))
|
||||
}
|
||||
|
||||
/// An icon or buttons number: `v` as a number, `None` when it is missing or isn't one (a value
|
||||
/// that can't be converted counts as missing, so a bad argument never stops the message).
|
||||
fn code(v: &JsValue, ctx: &mut Context) -> Option<f64> {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return None;
|
||||
}
|
||||
v.to_number(ctx).ok()
|
||||
}
|
||||
|
||||
/// `app.alert`'s message: `(cMsg, nIcon, nType, cTitle)`, or one object with those keys (as
|
||||
/// the JavaScript for Acrobat API Reference allows for every method with named parameters).
|
||||
pub(crate) fn alert_message(args: &[JsValue], ctx: &mut Context) -> JsResult<Message> {
|
||||
let first = arg(args, 0);
|
||||
let (text, icon, buttons, t) = match named_params(&first, "cMsg", ctx)? {
|
||||
Some(o) => {
|
||||
let get = |k: &str, ctx: &mut Context| o.get(JsString::from(k), ctx);
|
||||
(get("cMsg", ctx)?, get("nIcon", ctx)?, get("nType", ctx)?, get("cTitle", ctx)?)
|
||||
}
|
||||
None => (first, arg(args, 1), arg(args, 2), arg(args, 3)),
|
||||
};
|
||||
Ok(Message {
|
||||
text: text_line(&text, ctx)?,
|
||||
title: title(&t, ctx)?,
|
||||
icon: MessageIcon::from_code(code(&icon, ctx)),
|
||||
buttons: MessageButtons::from_code(code(&buttons, ctx)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox`'s message: `(message, title, icon, buttons)`, as the XFA
|
||||
/// specification orders them.
|
||||
pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResult<Message> {
|
||||
Ok(Message {
|
||||
text: text_line(&arg(args, 0), ctx)?,
|
||||
title: title(&arg(args, 1), ctx)?,
|
||||
icon: MessageIcon::from_code(code(&arg(args, 2), ctx)),
|
||||
buttons: MessageButtons::from_code(code(&arg(args, 3), ctx)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)`: records the message with its title, icon and buttons,
|
||||
/// given in that order or as one object with those keys, and returns 1 (the OK button).
|
||||
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = message(&arg(args, 0), "cMsg", ctx)?;
|
||||
let m = alert_message(args, ctx)?;
|
||||
host(ctx)?.borrow_mut().alert(m);
|
||||
// The OK button.
|
||||
// Always the OK button for now: the viewer can't ask the user and run the script on with
|
||||
// the answer yet (#4291).
|
||||
Ok(JsValue::from(1))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::model::{DocInfo, FieldState, FieldType, Request};
|
||||
use super::run::MAX_ARRAY_LENGTH;
|
||||
use super::{
|
||||
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell,
|
||||
xfa,
|
||||
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction,
|
||||
RefCell, xfa,
|
||||
};
|
||||
|
||||
pub(crate) struct Host {
|
||||
pub(crate) fields: Arc<Vec<FieldState>>,
|
||||
pub(crate) changed: BTreeSet<usize>,
|
||||
pub(crate) alerts: Vec<String>,
|
||||
pub(crate) alerts: Vec<Message>,
|
||||
pub(crate) console: Vec<String>,
|
||||
pub(crate) requests: Vec<Request>,
|
||||
pub(crate) doc: DocInfo,
|
||||
@@ -33,10 +33,12 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn alert(&mut self, m: String) {
|
||||
/// Keep message `m`, its text and title charged to the run's byte budget, up to
|
||||
/// [`xfa::MAX_ALERTS`] messages.
|
||||
pub(crate) fn alert(&mut self, m: Message) {
|
||||
if self.alerts.len() >= xfa::MAX_ALERTS {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
} else if self.room(m.len()) {
|
||||
} else if self.room(m.bytes()) {
|
||||
self.alerts.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ pub(crate) use globals::menu_item;
|
||||
use globals::platform;
|
||||
use host::{arg, as_number, error, function, s, text};
|
||||
pub use model::{
|
||||
DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Outcome, Panel, Request,
|
||||
DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Message, MessageButtons,
|
||||
MessageIcon, Outcome, Panel, Request,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use run::SCRIPT_STACK;
|
||||
|
||||
+107
-1
@@ -115,6 +115,111 @@ pub struct DocInfo {
|
||||
pub xfa: bool,
|
||||
}
|
||||
|
||||
/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s
|
||||
/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA
|
||||
/// specification alike.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MessageIcon {
|
||||
/// 0, the default.
|
||||
#[default]
|
||||
Error,
|
||||
/// 1.
|
||||
Warning,
|
||||
/// 2.
|
||||
Question,
|
||||
/// 3.
|
||||
Status,
|
||||
}
|
||||
|
||||
impl MessageIcon {
|
||||
/// The icon numbered `code`; a missing, fractional or out-of-range number is the default.
|
||||
pub fn from_code(code: Option<f64>) -> MessageIcon {
|
||||
match whole_code(code) {
|
||||
Some(1) => MessageIcon::Warning,
|
||||
Some(2) => MessageIcon::Question,
|
||||
Some(3) => MessageIcon::Status,
|
||||
_ => MessageIcon::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable name for tools and logs: `error`, `warning`, `question` or `status`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MessageIcon::Error => "error",
|
||||
MessageIcon::Warning => "warning",
|
||||
MessageIcon::Question => "question",
|
||||
MessageIcon::Status => "status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The buttons a script's message offers: `app.alert`'s `nType` and `xfa.host.messageBox`'s
|
||||
/// buttons, numbered 0 to 3 in both references.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MessageButtons {
|
||||
/// 0, the default.
|
||||
#[default]
|
||||
Ok,
|
||||
/// 1.
|
||||
OkCancel,
|
||||
/// 2.
|
||||
YesNo,
|
||||
/// 3.
|
||||
YesNoCancel,
|
||||
}
|
||||
|
||||
impl MessageButtons {
|
||||
/// The buttons numbered `code`; a missing, fractional or out-of-range number is the default.
|
||||
pub fn from_code(code: Option<f64>) -> MessageButtons {
|
||||
match whole_code(code) {
|
||||
Some(1) => MessageButtons::OkCancel,
|
||||
Some(2) => MessageButtons::YesNo,
|
||||
Some(3) => MessageButtons::YesNoCancel,
|
||||
_ => MessageButtons::Ok,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable name for tools and logs: `ok`, `ok_cancel`, `yes_no` or `yes_no_cancel`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MessageButtons::Ok => "ok",
|
||||
MessageButtons::OkCancel => "ok_cancel",
|
||||
MessageButtons::YesNo => "yes_no",
|
||||
MessageButtons::YesNoCancel => "yes_no_cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `code` as one of the numbers 0 to 3 the references define, when it is exactly one.
|
||||
fn whole_code(code: Option<f64>) -> Option<u8> {
|
||||
let c = code.filter(|c| c.is_finite() && c.fract() == 0.0 && (0.0..=3.0).contains(c))?;
|
||||
// In 0..=3 and whole, so the cast is exact.
|
||||
Some(c as u8)
|
||||
}
|
||||
|
||||
/// A message a script showed (`app.alert`, `xfa.host.messageBox`), with the title, icon and
|
||||
/// buttons it asked for. Its text and title are cut to a message's length.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub text: String,
|
||||
/// The dialog's title, when the script gave one.
|
||||
pub title: Option<String>,
|
||||
pub icon: MessageIcon,
|
||||
pub buttons: MessageButtons,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// A message with only text: no title, the default icon and an OK button.
|
||||
pub fn plain(text: impl Into<String>) -> Message {
|
||||
Message { text: text.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Bytes of text the message holds, charged to a run's budget.
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
self.text.len().saturating_add(self.title.as_ref().map_or(0, String::len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Something a script asked the viewer to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Request {
|
||||
@@ -173,7 +278,8 @@ pub struct Outcome {
|
||||
pub change: String,
|
||||
/// Fields whose value or properties the script changed, as they are now.
|
||||
pub changed: Vec<FieldState>,
|
||||
pub alerts: Vec<String>,
|
||||
/// The messages the script showed, in order.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
pub requests: Vec<Request>,
|
||||
/// The script's error (syntax error, exception, or a limit reached), if it failed.
|
||||
|
||||
+182
-10
@@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() {
|
||||
assert_eq!(o.value, "string:number");
|
||||
}
|
||||
|
||||
/// A validate script refuses a value with `event.rc = false` and says why in a message.
|
||||
#[test]
|
||||
fn validate_scripts_reject_with_rc_and_alert() {
|
||||
let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11"));
|
||||
assert!(!o.rc);
|
||||
assert_eq!(o.alerts, ["Too many"]);
|
||||
assert_eq!(o.alerts, [Message::plain("Too many")]);
|
||||
let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3"));
|
||||
assert!(o.rc);
|
||||
}
|
||||
@@ -251,9 +252,11 @@ fn kept_bytes(o: &Outcome) -> usize {
|
||||
Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(),
|
||||
_ => 0,
|
||||
};
|
||||
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
o.console.iter().map(String::len).sum::<usize>() + o.alerts.iter().map(Message::bytes).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Console lines, messages and requests are capped in number and in bytes, and what is left out is
|
||||
/// noted.
|
||||
#[test]
|
||||
fn exec_menu_item_requests_are_capped_like_other_requests() {
|
||||
let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", ""));
|
||||
@@ -275,11 +278,99 @@ fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
|
||||
assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len());
|
||||
assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len());
|
||||
assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len());
|
||||
assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097));
|
||||
assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097));
|
||||
assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o));
|
||||
assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last());
|
||||
}
|
||||
|
||||
/// The message `app.alert(args)` shows, from a button's Mouse Up script.
|
||||
fn alerted(args: &str) -> Message {
|
||||
let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.error, None, "{args}");
|
||||
assert_eq!(o.alerts.len(), 1, "{args}");
|
||||
o.alerts.into_iter().next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for
|
||||
/// Acrobat API Reference numbers, and the title.
|
||||
#[test]
|
||||
fn app_alert_keeps_its_title_icon_and_buttons() {
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'"));
|
||||
assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}");
|
||||
}
|
||||
}
|
||||
// Only the message: no title, the Error icon and the OK button, the defaults.
|
||||
assert_eq!(alerted("'Saved'"), Message::plain("Saved"));
|
||||
assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") });
|
||||
}
|
||||
|
||||
/// The single-object form names the same parameters, in any order, and reads nothing else.
|
||||
#[test]
|
||||
fn app_alert_reads_the_object_form() {
|
||||
let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo }
|
||||
);
|
||||
assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done"));
|
||||
}
|
||||
|
||||
/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a
|
||||
/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`.
|
||||
#[test]
|
||||
fn objects_that_name_no_parameter_are_the_message_itself() {
|
||||
assert_eq!(alerted("new String('Saved')"), Message::plain("Saved"));
|
||||
let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}");
|
||||
let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}");
|
||||
}
|
||||
|
||||
/// An icon or button number that isn't one of the four (fractional, negative, too large, not a
|
||||
/// number, or a value that can't be converted) is the default, and the message still shows;
|
||||
/// a number given as text counts.
|
||||
#[test]
|
||||
fn app_alert_falls_back_on_bad_icon_and_button_numbers() {
|
||||
for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] {
|
||||
let m = alerted(&format!("'Hi', {bad}, {bad}, ''"));
|
||||
assert_eq!(m, Message::plain("Hi"), "{bad}");
|
||||
}
|
||||
assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") });
|
||||
// Every call still answers OK (1) until answers are asked for (#4291).
|
||||
let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.value, "1");
|
||||
}
|
||||
|
||||
/// A title is cut to a message's length like the text, and both are charged to the run's byte
|
||||
/// budget: titles can't carry more text out than messages can.
|
||||
#[test]
|
||||
fn app_alert_titles_are_clipped_and_charged() {
|
||||
use crate::host::Host;
|
||||
let m = alerted("'Hi', 0, 0, new Array(100000).join('t')");
|
||||
assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len));
|
||||
// The title's bytes count against the budget with the text's, and a message the budget
|
||||
// has no room for is left out and counted.
|
||||
let mut h = Host {
|
||||
fields: Arc::new(Vec::new()),
|
||||
changed: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc(),
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") });
|
||||
assert_eq!(h.kept, "Saved".len() + "Order form".len());
|
||||
h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len();
|
||||
h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") });
|
||||
assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printd_reads_a_long_literal_run_in_linear_work() {
|
||||
crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -435,8 +526,8 @@ fn scripts_cannot_compile_code_from_strings() {
|
||||
}
|
||||
|
||||
mod xfa_model {
|
||||
use crate::Limits;
|
||||
use crate::xfa::*;
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode {
|
||||
XfaNode {
|
||||
@@ -670,6 +761,8 @@ mod xfa_model {
|
||||
assert_eq!(o.result.as_deref(), Some("4/1"));
|
||||
}
|
||||
|
||||
/// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep
|
||||
/// their details.
|
||||
#[test]
|
||||
fn host_layout_event_and_app_calls_become_effects() {
|
||||
let o = go(
|
||||
@@ -681,7 +774,12 @@ mod xfa_model {
|
||||
assert_eq!(
|
||||
o.effects,
|
||||
vec![
|
||||
XfaEffect::MessageBox("Hi".into()),
|
||||
XfaEffect::MessageBox(Message {
|
||||
text: "Hi".into(),
|
||||
title: Some("T".into()),
|
||||
icon: MessageIcon::Status,
|
||||
buttons: MessageButtons::OkCancel
|
||||
}),
|
||||
XfaEffect::ResetData(vec!["qty".into(), "price".into()]),
|
||||
XfaEffect::Print,
|
||||
XfaEffect::SaveAs,
|
||||
@@ -726,6 +824,7 @@ mod xfa_model {
|
||||
assert_eq!(o.result.as_deref(), Some("1002"));
|
||||
}
|
||||
|
||||
/// A value set again replaces the earlier one, and hostile loops end at the caps.
|
||||
#[test]
|
||||
fn values_set_again_merge_and_hostile_loops_are_capped() {
|
||||
// A calculate that sets its own value in a loop leaves one effect: the last value.
|
||||
@@ -747,7 +846,56 @@ mod xfa_model {
|
||||
assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes);
|
||||
// Long messages are cut.
|
||||
let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click");
|
||||
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
|
||||
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097));
|
||||
}
|
||||
|
||||
/// The message box `script` showed from a button's click script.
|
||||
fn boxed(script: &str) -> Message {
|
||||
let o = go(script, "form1[0].page1[0].go[0]", "click");
|
||||
assert_eq!(o.error, None, "{script}");
|
||||
match o.effects.as_slice() {
|
||||
[XfaEffect::MessageBox(m)] => m.clone(),
|
||||
other => panic!("{script}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the
|
||||
/// XFA specification numbers, and the title.
|
||||
#[test]
|
||||
fn message_box_keeps_its_title_icon_and_buttons() {
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})"));
|
||||
assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}");
|
||||
}
|
||||
}
|
||||
assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved"));
|
||||
// A bad number is the default; the message still shows.
|
||||
assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi"));
|
||||
assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi"));
|
||||
let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))");
|
||||
assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097));
|
||||
// Answers are always OK for now (#4291).
|
||||
let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click");
|
||||
assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects);
|
||||
}
|
||||
|
||||
/// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`)
|
||||
/// and its object form, not `messageBox`'s.
|
||||
#[test]
|
||||
fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() {
|
||||
let m = boxed("app.alert('Sure?', 1, 3, 'Order form')");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel }
|
||||
);
|
||||
let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel }
|
||||
);
|
||||
}
|
||||
|
||||
/// Past the byte budget, adding or removing a row is left out of the snapshot as well as the
|
||||
@@ -818,6 +966,7 @@ mod xfa_model {
|
||||
assert_eq!(names, 1);
|
||||
}
|
||||
|
||||
/// Time the engine takes to start is not taken from the script's own time.
|
||||
#[test]
|
||||
fn a_slow_engine_start_does_not_count_against_the_script() {
|
||||
let ms = std::time::Duration::from_millis;
|
||||
@@ -852,9 +1001,9 @@ mod xfa_model {
|
||||
|
||||
mod formcalc {
|
||||
use super::xfa_model::form;
|
||||
use crate::Limits;
|
||||
use crate::formcalc::run_formcalc;
|
||||
use crate::xfa::*;
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
|
||||
@@ -1165,6 +1314,7 @@ s"#
|
||||
assert!(o.error.unwrap().contains("endif"));
|
||||
}
|
||||
|
||||
/// FormCalc resolves form objects, assigns to them and records the effects.
|
||||
#[test]
|
||||
fn form_objects_resolve_assign_and_record_effects() {
|
||||
assert_formcalc_reads_fields_and_rows();
|
||||
@@ -1242,7 +1392,7 @@ table._row.count"#,
|
||||
XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() },
|
||||
XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() },
|
||||
XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() },
|
||||
XfaEffect::MessageBox("Total 17.5".into()),
|
||||
XfaEffect::MessageBox(Message::plain("Total 17.5")),
|
||||
XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()),
|
||||
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
|
||||
]
|
||||
@@ -1311,6 +1461,7 @@ $host.gotoURL("https://example.org")"#,
|
||||
assert_eq!(run(&script).result.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
/// Hostile FormCalc scripts end with an error and bounded output.
|
||||
#[test]
|
||||
fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() {
|
||||
assert_hostile_control_flow_is_bounded();
|
||||
@@ -1361,7 +1512,7 @@ $host.gotoURL("https://example.org")"#,
|
||||
);
|
||||
assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
|
||||
let o = run_hostile_formcalc("$host.messageBox(Space(100000))");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len());
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len());
|
||||
let o = run_hostile_formcalc("qty = Space(100000)");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len());
|
||||
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
|
||||
@@ -1467,6 +1618,27 @@ $host.gotoURL("https://example.org")"#,
|
||||
assert!(o.abandoned || o.error.is_some(), "{o:?}");
|
||||
}
|
||||
|
||||
/// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button
|
||||
/// set and the title, as the JavaScript one does.
|
||||
#[test]
|
||||
fn formcalc_message_box_keeps_its_title_icon_and_buttons() {
|
||||
let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click");
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})"));
|
||||
let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind };
|
||||
assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}");
|
||||
}
|
||||
}
|
||||
// Missing or bad numbers are the defaults; a long title is cut like the message.
|
||||
assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]);
|
||||
assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]);
|
||||
let o = fc("$host.messageBox(\"Hi\", Space(100000))");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)));
|
||||
}
|
||||
|
||||
fn run_hostile_formcalc(script: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
@@ -1884,5 +2056,5 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() {
|
||||
let o = go(XFA_CHECK, &Event::doc("Open"));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "undefined number 3.5");
|
||||
assert_eq!(o.alerts, ["old viewer"]);
|
||||
assert_eq!(o.alerts, [Message::plain("old viewer")]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
|
||||
use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
|
||||
mod globals;
|
||||
mod host;
|
||||
|
||||
@@ -9,17 +9,28 @@ use super::{
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
|
||||
/// Records effect `e` on the host, as [`XHost::push`] does.
|
||||
/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does.
|
||||
fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
|
||||
host(ctx)?.borrow_mut().push(e);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and
|
||||
/// returns 1.
|
||||
/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title,
|
||||
/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1.
|
||||
fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = clip(text(&arg(args, 0), ctx)?);
|
||||
let m = crate::globals::message_box_message(args, ctx)?;
|
||||
effect(ctx, XfaEffect::MessageBox(m))?;
|
||||
// Always OK for now: the viewer can't ask the user and run the script on with the answer
|
||||
// yet (#4291).
|
||||
Ok(JsValue::from(1.0))
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)` in an XFA form's script: the same message box, its
|
||||
/// arguments in `app.alert`'s order (or one object with those keys).
|
||||
fn app_alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = crate::globals::alert_message(args, ctx)?;
|
||||
effect(ctx, XfaEffect::MessageBox(m))?;
|
||||
// The OK button, as for `messageBox` (#4291).
|
||||
Ok(JsValue::from(1.0))
|
||||
}
|
||||
|
||||
@@ -211,7 +222,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
ctx.register_global_property(js_string!("event"), ev, rw)?;
|
||||
ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?;
|
||||
let app = ObjectInitializer::new(ctx)
|
||||
.function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4)
|
||||
.function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4)
|
||||
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2)
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::model::{
|
||||
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
|
||||
effect_key, payload,
|
||||
};
|
||||
use super::{Context, HashMap, JsResult, RefCell, error};
|
||||
use super::{Context, HashMap, JsResult, Message, RefCell, error};
|
||||
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
|
||||
pub(crate) struct HNode {
|
||||
@@ -133,7 +133,7 @@ impl XHost {
|
||||
/// `e` with its text cut to the lengths an effect keeps.
|
||||
fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect {
|
||||
match e {
|
||||
XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)),
|
||||
XfaEffect::MessageBox(m) => XfaEffect::MessageBox(Message { text: clip(m.text), title: m.title.map(clip), ..m }),
|
||||
XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)),
|
||||
XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)),
|
||||
XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::HashSet;
|
||||
use super::{HashSet, Message};
|
||||
|
||||
/// What kind of template object a node is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -95,7 +95,8 @@ pub enum XfaEffect {
|
||||
RemoveInstance {
|
||||
som: String,
|
||||
},
|
||||
MessageBox(String),
|
||||
/// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script.
|
||||
MessageBox(Message),
|
||||
/// `xfa.host.resetData()`: the named fields, or every field when empty.
|
||||
ResetData(Vec<String>),
|
||||
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
|
||||
@@ -209,7 +210,8 @@ pub(super) fn payload(e: &XfaEffect) -> usize {
|
||||
som.len().saturating_add(s.len())
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::MessageBox(m) => m.bytes(),
|
||||
XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0,
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ pub(super) struct TextDevice {
|
||||
actual: Option<(usize, String, Vec<Drawn>)>,
|
||||
/// Every `/ActualText` replacement made on the page.
|
||||
pub(super) replaced: Vec<Replaced>,
|
||||
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
|
||||
page: Rect,
|
||||
filter: TextFilter,
|
||||
excluded_depth: Option<usize>,
|
||||
actual_filtered: bool,
|
||||
actual_cropped: bool,
|
||||
partial_replacement: bool,
|
||||
}
|
||||
|
||||
@@ -109,9 +112,11 @@ impl TextDevice {
|
||||
depth: 0,
|
||||
actual: None,
|
||||
replaced: Vec::new(),
|
||||
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
filter: TextFilter::default(),
|
||||
excluded_depth: None,
|
||||
actual_filtered: false,
|
||||
actual_cropped: false,
|
||||
partial_replacement: false,
|
||||
}
|
||||
}
|
||||
@@ -126,12 +131,17 @@ impl TextDevice {
|
||||
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all.
|
||||
fn end_actual_text(&mut self) {
|
||||
let Some((_, text, drawn)) = self.actual.take() else { return };
|
||||
let cropped = std::mem::take(&mut self.actual_cropped);
|
||||
if std::mem::take(&mut self.actual_filtered) {
|
||||
// ActualText has no mapping from replacement words to individual glyphs. A mixed
|
||||
// span cannot be filtered honestly; a wholly excluded span can simply be omitted.
|
||||
self.partial_replacement |= !drawn.is_empty();
|
||||
return;
|
||||
}
|
||||
if cropped {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())));
|
||||
return;
|
||||
}
|
||||
let Some(glyph) = merge(&drawn, text.clone()) else {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control)));
|
||||
return;
|
||||
@@ -316,7 +326,13 @@ impl<'a> Device<'a> for TextDevice {
|
||||
let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
|
||||
Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y))
|
||||
});
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 {
|
||||
let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1);
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite())
|
||||
|| b.width() > 10_000.0
|
||||
|| b.height() > 10_000.0
|
||||
|| outside_crop
|
||||
{
|
||||
self.actual_cropped |= replaced && outside_crop;
|
||||
return;
|
||||
}
|
||||
let length = dx.hypot(dy);
|
||||
@@ -374,8 +390,10 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
|
||||
// as in Acrobat, and match what other extractors report.
|
||||
let settings = settings.clone();
|
||||
let initial = p.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings);
|
||||
let c = p.intersected_crop_box();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings);
|
||||
let mut dev = TextDevice::new();
|
||||
dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1));
|
||||
dev.filter = filter;
|
||||
interpret_page(p, &mut ctx, &mut dev);
|
||||
// A replacement the content never closed ends with the page.
|
||||
|
||||
@@ -1759,3 +1759,78 @@ fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() {
|
||||
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
|
||||
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_the_crop_box_is_clipped() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let ops = [
|
||||
"BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (ActualIn)>> BDC (in) Tj EMC ET",
|
||||
// Keep every glyph of OutSpan outside the crop; a boundary-crossing glyph remains visible.
|
||||
"BT /F1 12 Tf 1 0 0 1 -100 125 Tm /Span <</ActualText (CroppedSpan KeptSpan)>> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 370 Tm (AboveOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm /Span <</ActualText (ActualOut)>> BDC (out) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 46 100 Tm (A) Tj ET",
|
||||
]
|
||||
.join("\n");
|
||||
for rotate in [0, 90, 180, 270] {
|
||||
for unit in [1, 2] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => vec![Object::Integer(50), Object::Integer(60), Object::Integer(350), Object::Integer(340)],
|
||||
"Rotate" => rotate,
|
||||
"UserUnit" => unit,
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert_eq!(text.plain_text(), "Inside crop\nActualIn\nKeptSpan\nA", "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("KeptSpan").is_empty(), "rotate={rotate} unit={unit}");
|
||||
for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut", "CroppedSpan", "OutSpan"] {
|
||||
assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
|
||||
// even when a glyph straddles the clamped intersection point.
|
||||
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => crop.map(Object::Integer).to_vec(),
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl PdfCraftApp {
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
toast: None,
|
||||
toast_script: None,
|
||||
script_messages: std::collections::VecDeque::new(),
|
||||
script_messages_dropped: 0,
|
||||
toast_dropped: 0,
|
||||
integrated_titlebar: false,
|
||||
window_controls: None,
|
||||
system_title_bar: false,
|
||||
@@ -284,8 +288,13 @@ impl PdfCraftApp {
|
||||
|| self.updates.open
|
||||
}
|
||||
|
||||
/// Show `msg` in the toast now, in place of whatever it showed. Script messages still waiting
|
||||
/// show after it.
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
// A left-out count it replaces is said again once the toast is free.
|
||||
self.script_messages_dropped = self.script_messages_dropped.saturating_add(std::mem::take(&mut self.toast_dropped));
|
||||
self.toast = Some((msg.into(), 0.0));
|
||||
self.toast_script = None;
|
||||
}
|
||||
|
||||
/// Notify with a static message in the UI language.
|
||||
|
||||
@@ -69,10 +69,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
app.active = None;
|
||||
app.combine_tab.focused = false;
|
||||
}
|
||||
// Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs.
|
||||
// Reserve the three icon buttons, Discord and their gaps outside the scrolling tabs.
|
||||
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x)
|
||||
+ 2.0 * button
|
||||
+ 50.0
|
||||
+ 3.0 * button
|
||||
+ 54.0
|
||||
+ window_controls::width(&controls.right);
|
||||
// Open stays in sight after the tabs: its icon, text and padding, and the gaps around it.
|
||||
let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0;
|
||||
@@ -287,6 +287,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
ui.add_space(window_controls::SIDE_GAP - 4.0);
|
||||
}
|
||||
if icons::button(ui, "settings", button, false, tl!("Preferences")).clicked() {
|
||||
app.execute("app.preferences");
|
||||
}
|
||||
let (icon, label) = match app.theme_preference {
|
||||
ThemePreference::System => ("monitor", tl!("Use system setting")),
|
||||
ThemePreference::Light => ("sun", tl!("Light gray")),
|
||||
|
||||
@@ -359,6 +359,17 @@ pub struct PdfCraftApp {
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
pub pinned: folders_ui::PinnedFolders,
|
||||
pub toast: Option<(String, f64)>,
|
||||
/// The form script message the toast shows now, if it shows one: drawn with its title and
|
||||
/// icon (#4293).
|
||||
pub toast_script: Option<pdfcraft_engine::js::Message>,
|
||||
/// Form script messages waiting for the toast, in the order the scripts showed them: each
|
||||
/// takes the toast when the one before it is gone, so every one is seen (#4294).
|
||||
pub script_messages: std::collections::VecDeque<pdfcraft_engine::js::Message>,
|
||||
/// Form script messages the waiting queue had no room for, since the toast last said how many.
|
||||
pub script_messages_dropped: u64,
|
||||
/// The left-out count the toast is saying now ([`Self::next_script_message`]): put back to be
|
||||
/// said again if another notice replaces the toast before it has been shown its time.
|
||||
pub toast_dropped: u64,
|
||||
/// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
|
||||
pub integrated_titlebar: bool,
|
||||
/// Set when the desktop leaves the window frame to the app (GNOME on Wayland): the tab strip
|
||||
|
||||
@@ -254,6 +254,10 @@ fn english(labeled: Labeled<'_>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn translate(english: &'static str) -> String {
|
||||
// The bundled UI fonts do not all cover the menu's mathematical ellipsis.
|
||||
if english == "\u{22ef}" {
|
||||
return "...".to_owned();
|
||||
}
|
||||
tl!(english).to_owned()
|
||||
}
|
||||
|
||||
|
||||
@@ -433,14 +433,28 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru
|
||||
|
||||
/// Transient message at the bottom centre.
|
||||
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
// A form script message waiting its turn takes the toast once it is free.
|
||||
if app.toast.is_none() {
|
||||
app.toast_script = None;
|
||||
// A left-out count whose toast was cleared before its time is said again.
|
||||
app.script_messages_dropped = app.script_messages_dropped.saturating_add(std::mem::take(&mut app.toast_dropped));
|
||||
app.next_script_message();
|
||||
}
|
||||
let Some((msg, start)) = app.toast.clone() else { return };
|
||||
let now = ctx.input(|i| i.time);
|
||||
let start = if start == 0.0 { now } else { start };
|
||||
app.toast = Some((msg.clone(), start));
|
||||
if now - start > 3.5 {
|
||||
app.toast = None;
|
||||
app.toast_script = None;
|
||||
// Shown its time: a left-out count it said is done.
|
||||
app.toast_dropped = 0;
|
||||
if app.next_script_message() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let script = app.toast_script.as_ref().map(|m| (m.title.clone(), crate::js_ui::message_icon(m.icon)));
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Above the progress notice while a job runs.
|
||||
@@ -461,10 +475,26 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
// Allow natural short labels; wrap longer ones within the viewport,
|
||||
// including the frame's 32 points and 16-point outside margins.
|
||||
ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0));
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(msg).color(if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }))
|
||||
.wrap(),
|
||||
);
|
||||
let ink = if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE };
|
||||
let text = |ui: &mut egui::Ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap());
|
||||
};
|
||||
match script {
|
||||
// A form script's message: its icon, then its title (when it gave one)
|
||||
// over its text.
|
||||
Some((title, icon)) => {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.add(icons::image(icon, 16.0, ink));
|
||||
ui.vertical(|ui| {
|
||||
if let Some(title) = title {
|
||||
ui.add(egui::Label::new(egui::RichText::new(title).strong().color(ink)).wrap());
|
||||
}
|
||||
text(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
None => text(ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript.
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use pdfcraft_engine::js::{JsOutput, Panel, Request};
|
||||
use pdfcraft_engine::js::{JsOutput, Message, MessageIcon, Panel, Request};
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -22,6 +22,21 @@ pub struct DocJsDraft {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Most form script messages that wait for the toast; past it, more are left out and counted, and
|
||||
/// the toast says how many once the rest have been shown. One run shows at most this many (the
|
||||
/// script engine's own limit), so only runs in quick succession reach it.
|
||||
const MAX_WAITING_MESSAGES: usize = 100;
|
||||
|
||||
/// The icon a script message's toast shows for `icon`.
|
||||
pub(crate) fn message_icon(icon: MessageIcon) -> &'static str {
|
||||
match icon {
|
||||
MessageIcon::Error => "circle-x",
|
||||
MessageIcon::Warning => "triangle-alert",
|
||||
MessageIcon::Question => "circle-help",
|
||||
MessageIcon::Status => "info",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user is told when a form's scripts were turned off: in a toast as it happens, then
|
||||
/// on the notice bar for as long as the document is open.
|
||||
pub(crate) fn scripts_off_notice() -> String {
|
||||
@@ -81,11 +96,47 @@ impl PdfCraftApp {
|
||||
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
|
||||
}
|
||||
}
|
||||
if let Some(a) = out.alerts.last() {
|
||||
self.notify(a.clone());
|
||||
self.show_script_messages(out.alerts);
|
||||
}
|
||||
|
||||
/// Show form script messages `messages` in order, each in the toast with its title and icon.
|
||||
/// The first takes the toast at once when it is free; otherwise it waits, like the rest, for
|
||||
/// what the toast shows (another message, or a notice such as the run's own submit request)
|
||||
/// to go ([`Self::next_script_message`]).
|
||||
pub fn show_script_messages(&mut self, messages: Vec<Message>) {
|
||||
let room = MAX_WAITING_MESSAGES.saturating_sub(self.script_messages.len());
|
||||
let left_out = u64::try_from(messages.len().saturating_sub(room)).unwrap_or(u64::MAX);
|
||||
self.script_messages_dropped = self.script_messages_dropped.saturating_add(left_out);
|
||||
self.script_messages.extend(messages.into_iter().take(room));
|
||||
if self.toast.is_none() {
|
||||
self.next_script_message();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the next waiting script message in the toast, or, once none waits, a notice of how many
|
||||
/// the queue had no room for. False when there is neither.
|
||||
pub fn next_script_message(&mut self) -> bool {
|
||||
let Some(m) = self.script_messages.pop_front() else {
|
||||
// Every message the queue held has been shown: say how many it had no room for.
|
||||
if self.script_messages_dropped == 0 {
|
||||
return false;
|
||||
}
|
||||
let n = std::mem::take(&mut self.script_messages_dropped);
|
||||
self.notify(crate::i18n::trn(
|
||||
crate::i18n::current(),
|
||||
n,
|
||||
"{n} more message from the form's scripts was left out.",
|
||||
"{n} more messages from the form's scripts were left out.",
|
||||
));
|
||||
// Kept with the toast until it has been shown its time (the toast's own expiry).
|
||||
self.toast_dropped = n;
|
||||
return true;
|
||||
};
|
||||
self.toast = Some((m.text.clone(), 0.0));
|
||||
self.toast_script = Some(m);
|
||||
true
|
||||
}
|
||||
|
||||
/// Run a push button's JavaScript (its Mouse Up action).
|
||||
pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) {
|
||||
// A script reads the fields: include what's still being typed in one (#166).
|
||||
@@ -265,18 +316,6 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
close
|
||||
}
|
||||
|
||||
/// The label for a [`pdfcraft_render::ReadingColors`] mode name (`mode_name`).
|
||||
fn reading_mode_label(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"black-on-white" => "Black on white",
|
||||
"white-on-black" => "White on black",
|
||||
"yellow-on-black" => "Yellow on black",
|
||||
"green-on-black" => "Green on black",
|
||||
"custom" => "Custom",
|
||||
_ => "Off",
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the Preferences dialog keeps below its scrolling settings for the OK row: the
|
||||
/// button, and the space above it.
|
||||
pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0;
|
||||
@@ -394,6 +433,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
ui.add_space(8.0);
|
||||
crate::appearance::preferences_section(ui, app, t);
|
||||
ui.add_space(8.0);
|
||||
reading_colours_section(ui, app, t);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Default workspace mode"));
|
||||
@@ -478,70 +519,6 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
.on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme."));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are
|
||||
// remapped while rendering, and the page clears to the background colour. The preference
|
||||
// is app-wide: every open document follows it, and documents open with it.
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Replace document colours"));
|
||||
let mode = app.reading_colors.mode_name();
|
||||
craft_ui::ComboBox::from_id_salt("reading-colors")
|
||||
.selected_text(tl!(reading_mode_label(mode)))
|
||||
.show_ui(ui, |ui| {
|
||||
for (m, label) in [
|
||||
("off", "Off"),
|
||||
("black-on-white", "Black on white"),
|
||||
("white-on-black", "White on black"),
|
||||
("yellow-on-black", "Yellow on black"),
|
||||
("green-on-black", "Green on black"),
|
||||
("custom", "Custom"),
|
||||
] {
|
||||
if ui.selectable_label(mode == m, tl!(label)).clicked() {
|
||||
match m {
|
||||
"off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()),
|
||||
"custom" => {
|
||||
// Custom starts from the colours already chosen, or black on white.
|
||||
let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| {
|
||||
pdfcraft_render::ReadingColors { params: Some(params) }
|
||||
});
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
// `parse` knows these names; a preset comes with its own flags.
|
||||
preset => {
|
||||
if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
if app.reading_colors.params.is_some() {
|
||||
let mut changed = false;
|
||||
if let Some(params) = app.reading_colors.params.as_mut() {
|
||||
ui.horizontal(|ui| {
|
||||
let text = ui.label(tl!("Text"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed();
|
||||
let background = ui.label(tl!("Page background"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed();
|
||||
});
|
||||
if ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed() {
|
||||
changed = true;
|
||||
}
|
||||
if ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.set_reading_colors(app.reading_colors);
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted));
|
||||
ui.add_space(8.0);
|
||||
// Identity: the author of new comments (Acrobat: Preferences ▸ Identity).
|
||||
ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -679,6 +656,75 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
);
|
||||
}
|
||||
|
||||
fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
// Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are
|
||||
// remapped while rendering, and the page clears to the background colour. The preference
|
||||
// is app-wide: every open document follows it, and documents open with it.
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0)));
|
||||
let mode = app.reading_colors.mode_name();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (name, label, colors) in [
|
||||
("off", "Off", pdfcraft_render::ReadingColors::default()),
|
||||
("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE),
|
||||
("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK),
|
||||
("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK),
|
||||
("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK),
|
||||
] {
|
||||
let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams {
|
||||
text: [0, 0, 0],
|
||||
background: [255, 255, 255],
|
||||
only_black: false,
|
||||
line_art: true,
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(104.0);
|
||||
let sample = egui::RichText::new("Aa").size(22.0).color(egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]));
|
||||
let button = egui::Button::new(sample)
|
||||
.fill(egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]))
|
||||
.min_size(egui::vec2(94.0, 44.0))
|
||||
.stroke(egui::Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border }));
|
||||
let response = ui.add(button);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label)));
|
||||
if response.clicked() {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
ui.label(tl!(label));
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if let Some(params) = app.reading_colors.params {
|
||||
let text = egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]);
|
||||
let background = egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]);
|
||||
egui::Frame::new().fill(background).inner_margin(16.0).show(ui, |ui| {
|
||||
ui.set_min_width((ui.available_width() - 32.0).max(0.0));
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).size(18.0).color(text));
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text));
|
||||
});
|
||||
} else {
|
||||
ui.label(tl!("Original"));
|
||||
}
|
||||
egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| {
|
||||
let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE };
|
||||
let mut changed = false;
|
||||
if let Some(params) = colors.params.as_mut() {
|
||||
ui.horizontal(|ui| {
|
||||
let text = ui.label(tl!("Text"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed();
|
||||
let background = ui.label(tl!("Page background"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed();
|
||||
});
|
||||
changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed();
|
||||
changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed();
|
||||
}
|
||||
if changed {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -70,6 +70,7 @@ fn console_runs_scripts_against_the_document() {
|
||||
assert!(h.state().js_console.log.is_empty(), "{:?} {:?}", h.state().js_console.log, h.state().dialog);
|
||||
}
|
||||
|
||||
/// A button's script fills a field and its message takes the toast.
|
||||
#[test]
|
||||
fn button_scripts_run_and_alert() {
|
||||
let mut h = harness();
|
||||
@@ -80,6 +81,80 @@ fn button_scripts_run_and_alert() {
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "Filled in");
|
||||
}
|
||||
|
||||
/// One run's messages are all shown, in order (#4294): the first takes the toast with its title
|
||||
/// and icon (#4293), the next takes it when the first is gone, and a notice shown meanwhile
|
||||
/// doesn't lose the ones still waiting. Messages past the queue's room are counted and reported
|
||||
/// once the rest have been shown.
|
||||
#[test]
|
||||
fn every_message_a_script_shows_takes_the_toast_in_turn() {
|
||||
use pdfcraft_engine::js::{MessageButtons, MessageIcon};
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "app.alert('First', 1, 0, 'Order form'); app.alert('Second', 3); app.alert('Third');");
|
||||
h.run_steps(2);
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>| h.state().toast.clone().map(|t| t.0);
|
||||
assert_eq!(shown(&h).as_deref(), Some("First"));
|
||||
let m = h.state().toast_script.clone().expect("the toast shows the script's message");
|
||||
assert_eq!((m.title.as_deref(), m.icon, m.buttons), (Some("Order form"), MessageIcon::Warning, MessageButtons::Ok));
|
||||
assert!(h.query_by_label("Order form").is_some(), "the title is drawn");
|
||||
assert_eq!(h.state().script_messages.len(), 2);
|
||||
// The first is gone: the second takes its place, with its own icon and no title.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Second"));
|
||||
assert_eq!(h.state().toast_script.as_ref().map(|m| (m.title.clone(), m.icon)), Some((None, MessageIcon::Status)));
|
||||
// A notice of the app's own replaces it; the third still follows.
|
||||
h.state_mut().notify("Saved");
|
||||
assert_eq!(h.state().toast_script, None);
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Third"));
|
||||
assert!(h.state().script_messages.is_empty());
|
||||
// A notice the same run raises first (its submit request) isn't replaced: the message
|
||||
// follows it.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
h.state_mut().run_button_script(id, "hello", "this.submitForm('https://example.com/form'); app.alert('Fourth');");
|
||||
h.run_steps(2);
|
||||
let notice = shown(&h).unwrap_or_default();
|
||||
assert!(notice.contains("submitted"), "the submit notice stays: {notice}");
|
||||
assert_eq!(h.state().script_messages.len(), 1);
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Fourth"));
|
||||
// Past the 100 the queue holds, messages are counted, and the toast says how many once the
|
||||
// rest have been shown.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
let many: Vec<_> = (0..105).map(|i| pdfcraft_engine::js::Message::plain(format!("Message {i}"))).collect();
|
||||
h.state_mut().show_script_messages(many);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Message 0"));
|
||||
for i in 1..100 {
|
||||
assert!(h.state_mut().next_script_message());
|
||||
assert_eq!(shown(&h), Some(format!("Message {i}")));
|
||||
}
|
||||
assert!(h.state_mut().next_script_message(), "the notice comes after the last message");
|
||||
assert_eq!(shown(&h).as_deref(), Some("5 more messages from the form's scripts were left out."));
|
||||
assert_eq!(h.state().toast_script, None, "an app notice, not a script message");
|
||||
assert!(!h.state_mut().next_script_message(), "said once");
|
||||
// Another notice replacing it before its time doesn't lose the count: once that notice has
|
||||
// been shown its time, the count is said again, and then, its own time over, not again.
|
||||
h.state_mut().notify("Saved");
|
||||
assert_eq!(shown(&h).as_deref(), Some("Saved"));
|
||||
let left_out = "5 more messages from the form's scripts were left out.";
|
||||
let mut said_again = false;
|
||||
for _ in 0..600 {
|
||||
h.run_steps(1);
|
||||
said_again |= shown(&h).as_deref() == Some(left_out);
|
||||
if said_again && shown(&h).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(said_again, "the count is said again after the notice that replaced it");
|
||||
assert_eq!(shown(&h), None, "and once only");
|
||||
assert!(!h.state_mut().next_script_message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_scripts_and_preferences() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -29,7 +29,7 @@ fn ok_is_on_screen_without_scrolling_at_any_window_height() {
|
||||
let h = harness(height);
|
||||
let ok = h.get_by_label("OK").rect();
|
||||
assert!(on_screen(ok, height), "{height}: OK at {ok:?} is off the {height} pt window");
|
||||
let title = h.get_by_label("Preferences").rect();
|
||||
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
|
||||
assert!(on_screen(title, height), "{height}: the title at {title:?} is off the window");
|
||||
// The OK row sits below the scrolled settings, never over them: the settings' last visible
|
||||
// row ends above it. The title is above everything.
|
||||
@@ -43,13 +43,13 @@ fn ok_closes_the_dialog_in_a_short_window_and_the_settings_still_scroll() {
|
||||
// A setting far down the list starts out below the visible settings; scrolling brings it in
|
||||
// between the title and the OK row, and OK did not move.
|
||||
let ok_before = h.get_by_label("OK").rect();
|
||||
let title = h.get_by_label("Preferences").rect();
|
||||
let far = h.get_by_label_contains("Reading colours").rect();
|
||||
assert!(far.min.y >= ok_before.min.y, "Reading colours starts out of sight: {far:?} vs OK {ok_before:?}");
|
||||
h.get_by_label_contains("Reading colours").scroll_to_me();
|
||||
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
|
||||
let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect();
|
||||
assert!(far.min.y >= ok_before.min.y, "JavaScript setting starts out of sight: {far:?} vs OK {ok_before:?}");
|
||||
h.get_by_label_contains("Enable Acrobat JavaScript").scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let far = h.get_by_label_contains("Reading colours").rect();
|
||||
assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "Reading colours scrolled in between the title and OK: {far:?}");
|
||||
let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect();
|
||||
assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "JavaScript setting scrolled in between the title and OK: {far:?}");
|
||||
assert_eq!(h.get_by_label("OK").rect(), ok_before, "OK stays put while the settings scroll");
|
||||
assert!(h.state().dialog.is_some());
|
||||
h.get_by_label("OK").click();
|
||||
@@ -64,9 +64,15 @@ fn the_dialog_opens_at_its_full_size_and_does_not_grow_afterwards() {
|
||||
// in its first frames landed on controls that were still moving.
|
||||
for height in [260.0, 560.0, 900.0] {
|
||||
let mut h = harness(height);
|
||||
let settled = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect());
|
||||
let settled = (
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(),
|
||||
h.get_by_label("OK").rect(),
|
||||
);
|
||||
h.run_steps(8);
|
||||
let later = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect());
|
||||
let later = (
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(),
|
||||
h.get_by_label("OK").rect(),
|
||||
);
|
||||
assert_eq!(settled, later, "{height}: the dialog moved after it had been shown for six frames");
|
||||
}
|
||||
// And it is in place within a few frames of opening, for the first click: a frame or two of
|
||||
|
||||
@@ -100,17 +100,6 @@ fn png() -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The Reading colours combo of the preferences dialog: it shows the chosen mode as its value,
|
||||
/// and no other combo in the dialog shows `Off` while the setting is off.
|
||||
fn combo(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
// The preferences dialog grew taller than the window, which leaves this combo below the
|
||||
// fold where a click lands outside the dialog and dismisses it; scroll it into view and
|
||||
// let the scroll settle before clicking.
|
||||
h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).click();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_colours_preference_round_trips() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
@@ -122,11 +111,9 @@ fn reading_colours_preference_round_trips() {
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().reading_colors, ReadingColors::default());
|
||||
// Switch the preset to white-on-black in the dialog's Reading colours combo. The open
|
||||
// document keeps repainting, so run a fixed number of steps and let the popup settle.
|
||||
combo(&mut h);
|
||||
h.run_steps(20);
|
||||
h.get_by_label("White on black").click();
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().reading_colors, ReadingColors::WHITE_ON_BLACK);
|
||||
// The preference is app-wide: the already open document follows it too.
|
||||
@@ -151,6 +138,41 @@ fn reading_colours_preference_round_trips() {
|
||||
assert_eq!(fresh.reading_colors, ReadingColors::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_presets_follow_appearance_and_do_not_change_the_interface_theme() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_option("dialog", "preferences").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.query_by_label("Original").is_some());
|
||||
let theme = h.state().theme_preference;
|
||||
let appearance = h.get_by_label("Light theme").rect();
|
||||
let preset = h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).rect();
|
||||
let documents = h.get_by_label("Documents and view").rect();
|
||||
assert!(appearance.bottom() < preset.top() && preset.bottom() < documents.top());
|
||||
for (label, expected) in [
|
||||
("White on black", ReadingColors::WHITE_ON_BLACK),
|
||||
("Yellow on black", ReadingColors::YELLOW_ON_BLACK),
|
||||
("Green on black", ReadingColors::GREEN_ON_BLACK),
|
||||
("Black on white", ReadingColors::BLACK_ON_WHITE),
|
||||
("Off", ReadingColors::default()),
|
||||
] {
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().reading_colors, expected);
|
||||
assert_eq!(h.state().theme_preference, theme);
|
||||
assert_eq!(h.query_by_label("Original").is_some(), expected.params.is_none());
|
||||
}
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_READING_SHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_colours_seed_on_open() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
|
||||
@@ -19,6 +19,20 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String {
|
||||
format!("{:?}", h.state().theme_preference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_gear_is_right_of_theme_and_opens_preferences() {
|
||||
let mut h = harness();
|
||||
let theme = h.get_by_label("Display theme: Use system setting").rect();
|
||||
let gear = h.get_by_label("Preferences").rect();
|
||||
assert!(gear.left() >= theme.right());
|
||||
h.get_by_label("Preferences").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.query_by_label("Appearance mode").is_some());
|
||||
assert!(h.query_by_label("Light theme").is_some());
|
||||
assert!(h.query_by_label("Dark theme").is_some());
|
||||
assert_eq!(preference(&h), "System");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -706,11 +706,11 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let last = h.get_by_label("narrow-11.pdf").rect();
|
||||
let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect();
|
||||
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}");
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(4);
|
||||
let first = h.get_by_label("narrow-00.pdf").rect();
|
||||
let first = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-00.pdf")).rect();
|
||||
assert!(first.center().x > 28.0 && first.right() < width - 100.0);
|
||||
h.event(egui::Event::PointerMoved(first.center()));
|
||||
for _ in 0..8 {
|
||||
@@ -723,9 +723,9 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
|
||||
});
|
||||
h.run_steps(8);
|
||||
}
|
||||
let last = h.get_by_label("narrow-11.pdf").rect();
|
||||
let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect();
|
||||
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}");
|
||||
h.get_by_label("narrow-11.pdf").click();
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().active, Some(11));
|
||||
assert!(h.get_by_label_contains("Display theme:").rect().right() <= width);
|
||||
|
||||
@@ -40,7 +40,9 @@ including CDATA and character references. Nested-element and XFA interpretation
|
||||
|
||||
CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV
|
||||
delimiters without changing field identities. Automatic detection of externally authored, ambiguous
|
||||
tables remains heuristic.
|
||||
tables remains heuristic. Both write a cell that a spreadsheet would run as a formula (`=` or `@`,
|
||||
or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that
|
||||
export added, so exported data imports back unchanged.
|
||||
|
||||
Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
|
||||
appearances PdfCraft can draw, and sets values through the form's own checks (formats,
|
||||
|
||||
+37
-11
@@ -112,7 +112,11 @@ pub fn export_data(doc: &Document, format: Format) -> String {
|
||||
out
|
||||
}
|
||||
Format::Csv => {
|
||||
let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
// A formula-like cell gets a leading `'`, as in `merge_csv`; import takes it off again.
|
||||
let q = |s: &str| {
|
||||
let s = if csv_guarded(s) { format!("'{s}") } else { s.to_string() };
|
||||
if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s }
|
||||
};
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
fields.iter().map(|f| q(&f.0)).collect::<Vec<_>>().join(","),
|
||||
@@ -184,15 +188,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec<String>)>]) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
let literal = |s: &str| {
|
||||
let t = s.trim_start();
|
||||
let formula = match t.chars().next() {
|
||||
Some('=' | '@') => true,
|
||||
Some('+' | '-') => !t.parse::<f64>().is_ok_and(f64::is_finite),
|
||||
_ => false,
|
||||
};
|
||||
if formula { format!("'{s}") } else { s.to_string() }
|
||||
};
|
||||
let literal = |s: &str| if runs_as_formula(s) { format!("'{s}") } else { s.to_string() };
|
||||
let q = |s: &str| {
|
||||
let s = literal(s);
|
||||
if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s }
|
||||
@@ -207,6 +203,32 @@ pub fn merge_csv(files: &[Vec<(String, Vec<String>)>]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a spreadsheet program runs CSV cell `s` as a formula: after any leading whitespace it
|
||||
/// starts with `=` or `@`, or with `+` or `-` and is not a plain number.
|
||||
fn runs_as_formula(s: &str) -> bool {
|
||||
let t = s.trim_start();
|
||||
match t.chars().next() {
|
||||
Some('=' | '@') => true,
|
||||
Some('+' | '-') => !t.parse::<f64>().is_ok_and(f64::is_finite),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Export Data writes CSV cell `s` with a leading `'`: it runs as a formula, or it already
|
||||
/// starts with `'` in front of one. That second case gets one more `'`, so [`csv_text`] can take
|
||||
/// exactly one off on import and every value comes back unchanged.
|
||||
fn csv_guarded(s: &str) -> bool {
|
||||
runs_as_formula(s.trim_start_matches('\''))
|
||||
}
|
||||
|
||||
/// A CSV cell's text without the `'` that Export Data added (see [`csv_guarded`]).
|
||||
fn csv_text(cell: String) -> String {
|
||||
match cell.strip_prefix('\'') {
|
||||
Some(rest) if csv_guarded(rest) => rest.to_string(),
|
||||
_ => cell,
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect the first logical record: tabs within quoted CSV cells are data. A quote
|
||||
// in the middle of an unquoted TSV name stays literal, as in the TSV reader.
|
||||
fn has_tab_delimiter(text: &str) -> bool {
|
||||
@@ -287,7 +309,11 @@ fn import_table(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) };
|
||||
// Text exported from a one-field form has no tab to show for it (#1321). CSV quotes a
|
||||
// value with a comma, so one name over several unquoted cells is that text, not CSV.
|
||||
if head.len() == 1 && row.len() > 1 { tab_delimited(t)? } else { head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() }
|
||||
if head.len() == 1 && row.len() > 1 {
|
||||
tab_delimited(t)?
|
||||
} else {
|
||||
head.into_iter().zip(row).map(|(k, v)| (csv_text(k), vec![csv_text(v)])).collect()
|
||||
}
|
||||
};
|
||||
apply_values(doc, &values, &mut report);
|
||||
Ok(report)
|
||||
|
||||
@@ -623,6 +623,45 @@ fn csv_exports_preserve_carriage_returns_in_names_and_values() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Data as CSV writes a name or value that a spreadsheet would run as a formula with a
|
||||
/// leading `'`, by the same rule as Merge Data Files, and Import Data gives it back unchanged.
|
||||
#[test]
|
||||
fn exported_csv_cells_never_start_a_formula_and_import_back_unchanged() {
|
||||
for (name, value, expected) in [
|
||||
("Name", "=1+1", "Name\n'=1+1\n"),
|
||||
("Name", "@SUM(A1:A2)", "Name\n'@SUM(A1:A2)\n"),
|
||||
("Name", "+1 555 0100", "Name\n'+1 555 0100\n"),
|
||||
("Name", "-A1", "Name\n'-A1\n"),
|
||||
("Name", " =1+1", "Name\n' =1+1\n"),
|
||||
("Name", "\t=1+1", "Name\n\"'\t=1+1\"\n"),
|
||||
("Name", "\r=1+1", "Name\n\"'\r=1+1\"\n"),
|
||||
("Name", "=1,2", "Name\n\"'=1,2\"\n"),
|
||||
("Name", "'=1+1", "Name\n''=1+1\n"),
|
||||
("Name", "-12.5", "Name\n-12.5\n"),
|
||||
("Name", "+1", "Name\n+1\n"),
|
||||
("Name", "'-5", "Name\n'-5\n"),
|
||||
("Name", "'plain", "Name\n'plain\n"),
|
||||
("Name", "a=b", "Name\na=b\n"),
|
||||
("=Name", "Ada", "'=Name\nAda\n"),
|
||||
] {
|
||||
let mut src = one_field();
|
||||
let field = fields(&src)[0].obj;
|
||||
src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||||
set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap();
|
||||
let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap());
|
||||
let src = Document::open(source.clone()).unwrap();
|
||||
let csv = export_data(&src, Format::Csv);
|
||||
assert_eq!(csv, expected, "{value:?}");
|
||||
|
||||
let mut dst = Document::open(source).unwrap();
|
||||
set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap();
|
||||
let report = import(&mut dst, csv.as_bytes()).unwrap();
|
||||
assert_eq!(report.fields, 1, "{value:?}");
|
||||
assert!(report.rejected.is_empty(), "{report:?}");
|
||||
assert_eq!(values(&dst), [(name.to_string(), vec![value.to_string()])]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the
|
||||
/// value used to split it and only the text before the comma was imported.
|
||||
#[test]
|
||||
|
||||
@@ -31,6 +31,10 @@ release_arch() {
|
||||
}
|
||||
# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to
|
||||
# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target.
|
||||
# host-tuple means the build host's triple, as rustc names it.
|
||||
if [[ "${CARGO_BUILD_TARGET:-}" == host-tuple ]]; then
|
||||
CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
fi
|
||||
case "${CARGO_BUILD_TARGET:-}" in
|
||||
"") ARCH="$(release_arch "$(uname -m)")" ;;
|
||||
aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;;
|
||||
@@ -47,7 +51,7 @@ BASENAME="pdfcraft-$VERSION-freebsd-$ARCH"
|
||||
echo "==> PdfCraft $VERSION for FreeBSD $ARCH"
|
||||
|
||||
if [[ "$SKIP_BUILD" = 0 ]]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli)
|
||||
(cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} -p pdfcraft -p pdfcraft-cli)
|
||||
fi
|
||||
# Cargo puts a cross build under target/<triple>/release.
|
||||
BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release"
|
||||
@@ -70,7 +74,7 @@ copy_docs "$STAGE/share/doc/pdfcraft"
|
||||
# which has to run here: build it for this machine, not for CARGO_BUILD_TARGET.
|
||||
(unset CARGO_BUILD_TARGET; stage_models "$STAGE/share/pdfcraft/models")
|
||||
|
||||
if [ "$ARCH" = "$HOST_ARCH" ]; then
|
||||
if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then
|
||||
"$STAGE/bin/pdfcraft" --version
|
||||
else
|
||||
# They cannot run here, so check what they are instead: executables, not libraries or objects.
|
||||
@@ -82,7 +86,7 @@ else
|
||||
*) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine"
|
||||
echo "skipping --version: a CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET build does not run on this $HOST_ARCH machine"
|
||||
fi
|
||||
mkdir -p "$DIST"
|
||||
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
|
||||
@@ -30,9 +30,10 @@ finish-args:
|
||||
# passed by path (the command line, Open Recent, "Save" next to the original) work there.
|
||||
- --filesystem=xdg-documents
|
||||
- --filesystem=xdg-download
|
||||
# Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check
|
||||
# for updates reports that GitHub is unreachable; the optional --control channel listens on
|
||||
# 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing
|
||||
# Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens
|
||||
# on 127.0.0.1 inside the sandbox.
|
||||
- --share=network
|
||||
# Deliberately not granted: home/host filesystem access. No CUPS socket either: printing
|
||||
# runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print
|
||||
# reports that the spooler is unavailable until it goes through the print portal.
|
||||
modules:
|
||||
|
||||
@@ -31,9 +31,10 @@ finish-args:
|
||||
# passed by path (the command line, Open Recent, "Save" next to the original) work there.
|
||||
- --filesystem=xdg-documents
|
||||
- --filesystem=xdg-download
|
||||
# Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check
|
||||
# for updates reports that GitHub is unreachable; the optional --control channel listens on
|
||||
# 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing
|
||||
# Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens
|
||||
# on 127.0.0.1 inside the sandbox.
|
||||
- --share=network
|
||||
# Deliberately not granted: home/host filesystem access. No CUPS socket either: printing
|
||||
# runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print
|
||||
# reports that the spooler is unavailable until it goes through the print portal.
|
||||
build-options:
|
||||
|
||||
@@ -80,6 +80,21 @@ sign() {
|
||||
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
|
||||
}
|
||||
|
||||
# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped
|
||||
# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked.
|
||||
# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try.
|
||||
stapler_retry() {
|
||||
local try
|
||||
for try in 1 2 3 4; do
|
||||
xcrun stapler "$1" "$2" && return 0
|
||||
[[ "$try" = 4 ]] && break
|
||||
echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2
|
||||
sleep $((try * 20))
|
||||
done
|
||||
echo "error: stapler $1 $(basename "$2") failed 4 times" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
notarize() {
|
||||
local file="$1" out id status
|
||||
echo "==> notarizing $(basename "$file") (this can take a few minutes)"
|
||||
@@ -127,8 +142,8 @@ codesign --verify --strict --deep --verbose=2 "$APP"
|
||||
if [[ "$NOTARIZE" = 1 ]]; then
|
||||
ditto -c -k --keepParent "$APP" "$WORK/PdfCraft-notarize.zip"
|
||||
notarize "$WORK/PdfCraft-notarize.zip"
|
||||
xcrun stapler staple "$APP"
|
||||
xcrun stapler validate "$APP"
|
||||
stapler_retry staple "$APP"
|
||||
stapler_retry validate "$APP"
|
||||
spctl --assess --type execute -vvv "$APP"
|
||||
fi
|
||||
|
||||
@@ -178,8 +193,8 @@ fi
|
||||
hdiutil_retry detach detach "$MOUNT" >/dev/null
|
||||
if [[ "$NOTARIZE" = 1 ]]; then
|
||||
notarize "$DMG"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
stapler_retry staple "$DMG"
|
||||
stapler_retry validate "$DMG"
|
||||
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
|
||||
fi
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user