diff --git a/Cargo.lock b/Cargo.lock index b201da8413..8df9cf6236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 24313021bb..436731c137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/pdfcraft-cli/tests/it/text.rs b/apps/pdfcraft-cli/tests/it/text.rs index 3d39a9e284..b7fb2511b7 100644 --- a/apps/pdfcraft-cli/tests/it/text.rs +++ b/apps/pdfcraft-cli/tests/it/text.rs @@ -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![ + 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:?}"); + } + } } diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 646ed22273..ab79bbfe54 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -18,14 +18,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 { 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 = 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 = 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 { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b4ac8873b..78a6805bb3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1135,7 +1135,7 @@ pub fn tools() -> Vec { 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." } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 78038d8ebd..fb904bacfe 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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); diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 6e08cde48f..ef86d6c67a 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -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> { @@ -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 = HashSet::new(); // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. let mut undrawn: HashSet = 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> >>"); + 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] diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index dd45b0e716..ec81066ff2 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -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, + /// The messages scripts showed, in order, with their titles, icons and buttons. + pub alerts: Vec, pub console: Vec, pub requests: Vec, /// Script errors (as Acrobat's console shows them). @@ -210,8 +211,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { - /// Run a field's `event` script on `target` with the fields as they are, keeping what it - /// printed or asked for; a failing script leaves the value as it was. + /// 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() @@ -239,7 +240,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(), } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed600d2e26..d61f9cb45d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4808,6 +4808,8 @@ fn scripted_form_with(helpers: &str) -> Vec { 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>) -> 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 diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 130b49e191..724db2fa84 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -411,6 +411,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) { for l in lines { if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { @@ -421,7 +422,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 { @@ -900,7 +902,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")))); } } } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 5c80177021..3e68ce081d 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -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; diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index a3218a78f6..0b2dfe4b94 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -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 { 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" => { diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 8a94258a7a..413df6b45d 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -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 { - 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> { + 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> { + 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 { + 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 { + 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 { + 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 { - 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)) } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index cc1ca47fe3..1419ba2a86 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -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>, pub(crate) changed: BTreeSet, - pub(crate) alerts: Vec, + pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, 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); } } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 896ad3466d..93f57e7528 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 12f1e17440..03263c56a7 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -117,6 +117,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) -> 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) -> 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) -> Option { + 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, + 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) -> 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 { @@ -177,7 +282,8 @@ pub struct Outcome { pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, - pub alerts: Vec, + /// The messages the script showed, in order. + pub alerts: Vec, pub console: Vec, pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index e857d4243e..7ee0a1bb7e 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -41,11 +41,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); } @@ -255,9 +256,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::() + o.requests.iter().map(req).sum::() + o.console.iter().map(String::len).sum::() + o.alerts.iter().map(Message::bytes).sum::() + o.requests.iter().map(req).sum::() } +/// 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", "")); @@ -279,11 +282,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); @@ -439,8 +530,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 { @@ -674,6 +765,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( @@ -685,7 +778,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, @@ -730,6 +828,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. @@ -751,7 +850,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 @@ -822,6 +970,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; @@ -856,9 +1005,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}; /// Run FormCalc `script` as `target`'s `activity` script on [`form`]. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { @@ -1170,6 +1319,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(); @@ -1247,7 +1397,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() }, ] @@ -1316,6 +1466,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(); @@ -1366,7 +1517,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); @@ -1472,6 +1623,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(); @@ -1949,5 +2121,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")]); } diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index cddf8f2eb2..eb275d1822 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -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; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index c970b2b381..4d93666e2b 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -9,8 +9,7 @@ use super::{ // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── -/// Records effect `e` (a value set, a message, a request) on the host for the engine to apply after -/// the script, 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(()) @@ -35,11 +34,22 @@ fn set_runtime_highlight(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js Ok(JsValue::undefined()) } -/// `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 { - 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 { + 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)) } @@ -234,7 +244,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); let app = ObjectInitializer::new(ctx) .accessor(js_string!("runtimeHighlight"), Some(highlight_get), Some(highlight_set), rw) - .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) diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index ea2d4d4332..ec222a4ca8 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -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()), diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 3500450162..816753de07 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -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)] @@ -97,7 +97,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), /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. @@ -213,7 +214,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::())), XfaEffect::Print | XfaEffect::SaveAs diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index 79bf05f7db..7c4881e8be 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -27,9 +27,12 @@ pub(super) struct TextDevice { actual: Option<(usize, String, Vec)>, /// Every `/ActualText` replacement made on the page. pub(super) replaced: Vec, + /// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`). + page: Rect, filter: TextFilter, excluded_depth: Option, 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. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 34493248bf..836157e738 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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 <> 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 <> 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 <> 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()); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 1c8ecbfef3..41f6b9b87e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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) { + // 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. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a46f2173c8..aa6233c133 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65bc7e4b1b..a151f8ecd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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, + /// 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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c8bef1cd..f58a7d972f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -254,6 +254,10 @@ fn english(labeled: Labeled<'_>) -> Option { } 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() } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cb34b5901b..1c688b9bb3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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)); diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index e6c4fa1c2c..4f266e70f2 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -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 { @@ -82,8 +97,20 @@ 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) { + 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(); } } @@ -97,6 +124,30 @@ impl PdfCraftApp { self.session.set_field_highlight(on); } + /// 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). @@ -276,18 +327,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; @@ -405,6 +444,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")); @@ -489,70 +530,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| { @@ -690,6 +667,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::*; diff --git a/crates/ui-egui/tests/it/javascript.rs b/crates/ui-egui/tests/it/javascript.rs index 2d525104bf..b4aaa4224f 100644 --- a/crates/ui-egui/tests/it/javascript.rs +++ b/crates/ui-egui/tests/it/javascript.rs @@ -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(); diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 3b8d41ec06..1ebe06b7a0 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -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 diff --git a/crates/ui-egui/tests/it/reading_colors.rs b/crates/ui-egui/tests/it/reading_colors.rs index 398a85e8e0..ac48d89d05 100644 --- a/crates/ui-egui/tests/it/reading_colors.rs +++ b/crates/ui-egui/tests/it/reading_colors.rs @@ -100,17 +100,6 @@ fn png() -> Vec { 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| { diff --git a/crates/ui-egui/tests/it/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs index 758aa1a277..6568fca156 100644 --- a/crates/ui-egui/tests/it/theme_button.rs +++ b/crates/ui-egui/tests/it/theme_button.rs @@ -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(); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index cd5ce8dbb1..69be82e7a7 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -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); diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index c137aabd93..d286a457c3 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -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, diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ba8cb6f7f7..dec32c7418 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -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::>().join(","), @@ -184,15 +188,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let literal = |s: &str| { - let t = s.trim_start(); - let formula = match t.chars().next() { - Some('=' | '@') => true, - Some('+' | '-') => !t.parse::().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 { 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::().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 { 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) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 52a4a5b86d..d2110d3dae 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -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] diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e22bca4eae..0533be9010 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -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//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" diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 10aedeeee0..91897d4f15 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -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: diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index 8ae41ae976..9e4579fc15 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -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: diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 50701e0c16..ef1b7f6c70 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 27fa3103f3..b90ac132e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5212,8 +5212,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" @@ -5308,8 +5308,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill", "js_run"] -evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"] -notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures" +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] +notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)." [[feature]] id = "form.xfa-data" @@ -9180,8 +9180,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background", "crates/ui-egui/tests/it/reading_colors.rs::reading_presets_follow_appearance_and_do_not_change_the_interface_theme", "crates/ui-egui/tests/it/theme_button.rs::preferences_gear_is_right_of_theme_and_opens_preferences"] +notes = "View only, off by default: Preferences ▸ Reading colours, directly below Appearance, offers colour samples and a live preview; the gear beside the theme button opens Preferences. Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged"