From 6e3a16f96a25734c107745535980b588399ea45c Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:38:08 +0000 Subject: [PATCH 01/14] Issue #2368: flattening keeps a NoRotate widget upright on a rotated page A widget (or any annotation) with the NoRotate flag is shown upright on a page with /Rotate: its appearance turns with the page about the rectangle's upper-left corner (ISO 32000-2 12.5.3), which is how MuPDF and Poppler draw it. Flattening drew the appearance onto the rectangle as it lies in user space (Algorithm 8.1 alone), so on a /Rotate 90 page the flattened widget turned sideways and moved while its neighbours without the flag stayed put. The placement matrix of a NoRotate annotation is now followed by that turn (`upright`), for 90, 180 and 270. Regression test: tests::flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page (a plain and a NoRotate widget on pages rotated 0/90/180/270; the plain one's cm is unchanged, the NoRotate one's is the turned matrix). Verified on qpdf's manual-appearances.pdf page 2 (/Rotate 90): Poppler's render of the flattened page differs from the original's by 80 pixels at 36 dpi (anti-aliasing), against 648 before, when the NoRotate widget's box moved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/edit/src/flatten.rs | 25 +++++++++++++++++++++++++ crates/edit/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..dbc356656f 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> { @@ -52,6 +53,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. @@ -157,6 +180,7 @@ fn flatten_which(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. @@ -193,6 +217,7 @@ fn flatten_which(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] From 644f7ab685d5e135dc57953e9c081c81ea5dfba6 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 18:51:55 +0800 Subject: [PATCH 02/14] fix(render): clip extracted text to the page CropBox (#2670) --- apps/pdfcraft-cli/tests/text.rs | 36 ++++++++++++++ crates/render/src/text.rs | 86 ++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/text.rs index 3d39a9e284..b7fb2511b7 100644 --- a/apps/pdfcraft-cli/tests/text.rs +++ b/apps/pdfcraft-cli/tests/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/render/src/text.rs b/crates/render/src/text.rs index b767e28314..5827a87f62 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -358,6 +358,8 @@ struct TextDevice { actual: Option<(usize, String, Vec)>, /// Every `/ActualText` replacement made on the page. replaced: Vec, + /// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`). + page: Rect, filter: TextFilter, excluded_depth: Option, actual_filtered: bool, @@ -431,6 +433,7 @@ 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, @@ -619,7 +622,12 @@ 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 { + 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 + || b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) + || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1) + { return; } let length = dx.hypot(dy); @@ -677,8 +685,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. @@ -2646,4 +2656,76 @@ mod tests { 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", + "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\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}"); + for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut"] { + 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()); + } + } } From cba63a26e412f33625420dc6580603f054b1f8f3 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 20:29:42 +0800 Subject: [PATCH 03/14] M3: wait for OS-open preparation before removing its fixture The open worker can still be reading after the harness's four startup frames. Removing the synthetic file then asserted zero tabs before opening completed. A fixture-specific 300 ms delay reproduced the exact ui.rs:843 failure on 644f7ab6; waiting by state passes under the same delay. Keep the file until its tab opens, retaining the one-tab, filename and OS-quit assertions. The diagnostic delay is removed. CropBox production code is unchanged. Narrowly adapted from Guillem Hermida's OS-event test repair in upstream https://github.com/storytold/pdfcraft/pull/2812 at 47e5398b6ba650dbc7008a23f5231b2767af41a0 (MIT OR Apache-2.0). No other changes from that PR are included. Validation: controlled red/green; final UI suite 52 passed, zero ignored; CropBox render regression passed with its fully qualified exact test name; strict UI Clippy all targets, formatting, layers, assets and wasm (31 crates). The CLI CropBox acceptance test will also be verified in automatic exact-head CI. Co-authored-by: Guillem Hermida --- ROADMAP.md | 2 ++ crates/ui-egui/tests/ui.rs | 3 +++ docs/roadmap.md | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..6ea1d13501 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-16 (M3, PR #2838 CI):** The OS-open regression keeps its synthetic file until asynchronous preparation opens its tab, then verifies the original one-tab, filename and quit behavior. A controlled worker delay reproduced the old zero-tab failure. CropBox behavior and estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c4..14ca669f26 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -839,6 +839,9 @@ fn files_and_quit_from_the_operating_system() { let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])])); let q = queue.clone(); let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + // Reading and preparation run on the open worker. Keep its source file until the tab + // is ready, regardless of how many UI frames the worker takes. + support::wait_until(&mut h, "the file the operating system opened", |app| !app.views.is_empty()); std::fs::remove_file(&path).ok(); assert_eq!(h.state().views.len(), 1, "the file opens in a tab"); h.get_by_label_contains(&name); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..8046c14774 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -101,7 +101,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors.. OS-open regression waits for asynchronous tab creation before removing its fixture (PR #2838 CI). | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | From c6bf3d0dbd96b5bca5817d4e49518458459b24c5 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 22:39:15 +0800 Subject: [PATCH 04/14] fix(render): avoid emitting full ActualText replacement when span is partially cropped (#2838) --- crates/render/src/text.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e9ad024775..d4066ec14c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -363,6 +363,7 @@ struct TextDevice { filter: TextFilter, excluded_depth: Option, actual_filtered: bool, + actual_cropped: bool, partial_replacement: bool, } @@ -437,6 +438,7 @@ impl TextDevice { filter: TextFilter::default(), excluded_depth: None, actual_filtered: false, + actual_cropped: false, partial_replacement: false, } } @@ -451,12 +453,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(|c| c.is_control()))); return; @@ -622,12 +629,14 @@ 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)) }); + 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 - || b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) - || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1) + || outside_crop { + self.actual_cropped |= replaced && outside_crop; return; } let length = dx.hypot(dy); @@ -2812,6 +2821,7 @@ mod tests { 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", + "BT /F1 12 Tf 1 0 0 1 5 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", @@ -2843,10 +2853,11 @@ mod tests { 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\nA", "rotate={rotate} unit={unit}"); + 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}"); - for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut"] { + 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}"); } } From 87e68246c892c58ffbdfff0e2614610ccbcb1f2d Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 22:43:01 +0800 Subject: [PATCH 05/14] style(render): rustfmt CropBox condition (#2838) --- crates/render/src/text.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index d4066ec14c..08e50ae37a 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -629,8 +629,7 @@ 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)) }); - 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); + 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 From d76516219d5df5a28efe24fbec3b9a8b7f66a221 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 23:09:40 +0800 Subject: [PATCH 06/14] M2: keep hidden ActualText fixture outside CropBox (#2838) --- crates/render/src/text.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 08e50ae37a..8299142d66 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -2820,7 +2820,8 @@ mod tests { 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", - "BT /F1 12 Tf 1 0 0 1 5 125 Tm /Span <> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) 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", From 20eebc1c33ff7e6f42ac1c56bf95192f628a9d97 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:16:40 -0400 Subject: [PATCH 07/14] deps: pin craft-libs at e11f9d9a so Cargo before 1.99 loads craft-menu (#5094) craft-libs#366 drops the default-features override on craft-keymap that older Cargo rejects. FreeBSD aarch64 builds with Rust 1.98.1 and failed with "no matching package named craft-menu found" (craft-libs#364). --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 14 +++++++------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b201da8413..4f8e8ac8cd 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", diff --git a/Cargo.toml b/Cargo.toml index 24313021bb..f86ebaa15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,17 +13,17 @@ 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" } From 45d95f1107014c81a3a49c3bc760bda3d90050f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:18 -0400 Subject: [PATCH 08/14] macOS packaging: retry stapler when Apple's CloudKit drops the connection Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 From 75360d1d7db06fed974f48c914d4c04132757187 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:26 -0400 Subject: [PATCH 09/14] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f8e8ac8cd..8df9cf6236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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 f86ebaa15f..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" @@ -25,39 +25,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. 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" From 67d4f181795c46d149cbc50b50799ddf125c3ddf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 13:17:18 -0400 Subject: [PATCH 10/14] Grant network access so update checks can reach GitHub (#5189) --- packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml | 7 ++++--- packaging/linux/flatpak/ai.storyteller.pdfcraft.yml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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: From 7555be3c7ef1d2dc22ae1327791552812603d520 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 15:48:56 -0400 Subject: [PATCH 11/14] Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#5205) The script already validated CARGO_BUILD_TARGET and named the archive after the arch, but cargo build ignored it, so a cross build compiled for the host. Pass --target, resolve host-tuple to the build host's triple first, and skip the --version smoke run for any CARGO_BUILD_TARGET build (an aarch64 binary cannot run on the x86_64 VM). --- packaging/freebsd/package.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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" From 40c22e3ea58e26f2450d88436b7ed24b88a37862 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:39:06 +0200 Subject: [PATCH 12/14] Export Data as CSV keeps formula-like cells as text, like the merge report (#2524) Export Data (CSV) wrote field names and values as they are, so a value from a received form that starts with = or @ (or + or - when it is not a number) runs as a formula when the CSV is opened in a spreadsheet. It now uses the rule merge_csv got in #4101, moved unchanged into runs_as_formula, so the merge report's output is the same. Import Data takes the added ' off again, so Export Data then Import Data gives back every value unchanged. --- crates/xfdf/README.md | 4 +++- crates/xfdf/src/lib.rs | 48 +++++++++++++++++++++++++++++++--------- crates/xfdf/src/tests.rs | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 12 deletions(-) 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] From 03996b10258288e723c4e07334c9cd1bae1f68a8 Mon Sep 17 00:00:00 2001 From: Lucas Sampaio Dias Date: Wed, 28 Oct 2026 18:41:29 -0300 Subject: [PATCH 13/14] Improve reading-colour preferences and add a settings shortcut (#2627) * M3: improve reading-colour preferences and add settings shortcut * M3: clarify the disabled reading-colour preview * M3: adapt rebased preferences to current UI dependencies --- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/workflows/js_ui.rs | 147 +++++++++--------- crates/ui-egui/tests/it/preferences_layout.rs | 24 +-- crates/ui-egui/tests/it/reading_colors.rs | 54 +++++-- crates/ui-egui/tests/it/theme_button.rs | 14 ++ crates/ui-egui/tests/it/ui.rs | 8 +- parity/acrobat-features.toml | 4 +- 8 files changed, 154 insertions(+), 110 deletions(-) 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/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/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 0ebdbb07a6..4296487e4c 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -265,18 +265,6 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok close } -/// The label for a [`pdfcraft_render::ReadingColors`] mode name (`mode_name`). -fn reading_mode_label(mode: &str) -> &'static str { - match mode { - "black-on-white" => "Black on white", - "white-on-black" => "White on black", - "yellow-on-black" => "Yellow on black", - "green-on-black" => "Green on black", - "custom" => "Custom", - _ => "Off", - } -} - /// The height the Preferences dialog keeps below its scrolling settings for the OK row: the /// button, and the space above it. pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; @@ -394,6 +382,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); + reading_colours_section(ui, app, t); + ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0))); ui.horizontal(|ui| { ui.label(tl!("Default workspace mode")); @@ -478,70 +468,6 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme.")); } ui.add_space(8.0); - // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are - // remapped while rendering, and the page clears to the background colour. The preference - // is app-wide: every open document follows it, and documents open with it. - ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); - ui.horizontal(|ui| { - let label = ui.label(tl!("Replace document colours")); - let mode = app.reading_colors.mode_name(); - craft_ui::ComboBox::from_id_salt("reading-colors") - .selected_text(tl!(reading_mode_label(mode))) - .show_ui(ui, |ui| { - for (m, label) in [ - ("off", "Off"), - ("black-on-white", "Black on white"), - ("white-on-black", "White on black"), - ("yellow-on-black", "Yellow on black"), - ("green-on-black", "Green on black"), - ("custom", "Custom"), - ] { - if ui.selectable_label(mode == m, tl!(label)).clicked() { - match m { - "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), - "custom" => { - // Custom starts from the colours already chosen, or black on white. - let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { - pdfcraft_render::ReadingColors { params: Some(params) } - }); - app.set_reading_colors(colors); - } - // `parse` knows these names; a preset comes with its own flags. - preset => { - if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { - app.set_reading_colors(colors); - } - } - } - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - }); - if app.reading_colors.params.is_some() { - let mut changed = false; - if let Some(params) = app.reading_colors.params.as_mut() { - ui.horizontal(|ui| { - let text = ui.label(tl!("Text")); - changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); - let background = ui.label(tl!("Page background")); - changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); - }); - if ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed() { - changed = true; - } - if ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed() { - changed = true; - } - } - if changed { - app.set_reading_colors(app.reading_colors); - } - } - ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); - ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); ui.horizontal(|ui| { @@ -679,6 +605,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/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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ac06fdd4..2a17a9f02e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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" From 21f6c29d3e4ab3f79d81aad86412584f3efc3d36 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:46:11 -0400 Subject: [PATCH 14/14] M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294) (#4306) * M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294) * M6: a form script message waits for a notice the toast already shows * M6: form script messages past the queue's room are counted and reported * M6: a sentence break in the merged parity note * M6: an object that names no parameter is a message's text, as a String object or a caught error * M6: the count of left-out script messages is said again if another notice replaces it before its time * M6: a full stop between two sentences of the FormCalc parity note * chore: re-run CI (a frame-budget timing test missed on a shared runner) --- crates/automation/src/a11y/form_tools.rs | 13 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 21 ++- crates/engine/src/js.rs | 9 +- crates/engine/src/tests.rs | 191 +++++++++++++--------- crates/engine/src/xfa.rs | 6 +- crates/js/src/formcalc.rs | 2 +- crates/js/src/formcalc/eval.rs | 16 +- crates/js/src/globals.rs | 74 ++++++++- crates/js/src/host.rs | 12 +- crates/js/src/lib.rs | 3 +- crates/js/src/model.rs | 108 ++++++++++++- crates/js/src/tests.rs | 192 +++++++++++++++++++++-- crates/js/src/xfa.rs | 2 +- crates/js/src/xfa/globals.rs | 21 ++- crates/js/src/xfa/host.rs | 4 +- crates/js/src/xfa/model.rs | 8 +- crates/ui-egui/src/app.rs | 9 ++ crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/widgets.rs | 38 ++++- crates/ui-egui/src/workflows/js_ui.rs | 57 ++++++- crates/ui-egui/tests/it/javascript.rs | 75 +++++++++ parity/acrobat-features.toml | 12 +- 23 files changed, 741 insertions(+), 145 deletions(-) diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 7272a8d669..2420a44372 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { } } +/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), +/// icon and buttons. +fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { + json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) +} + impl Automation { + /// `js_run`: run `script` in the document, or as button `field`'s script, and report its + /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { 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 6891cad3f1..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); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 7f94971cc1..d1ee5c15b0 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). @@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and + /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { let states: Vec<_> = fields .iter() @@ -218,7 +221,7 @@ impl Scripts for JsRunner { rc: o.rc, value: o.value.clone(), changes: changes(&states, &o.changed), - message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(), + message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 14a61b2633..84e295d364 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 @@ -8737,69 +8774,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index dd011d47fc..f20c82f6c6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> { } } + /// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted. fn console(&mut self, lines: impl IntoIterator) { for l in lines { if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { @@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> { } } - fn alert(&mut self, m: String) { + /// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted. + fn alert(&mut self, m: crate::js::Message) { if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT { self.run.out.alerts.push(m); } else { @@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names for ev in r.events_of(&som, "validate") { let Some(o) = r.run(&ev, &value)? else { continue }; if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() { - r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))); + r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")))); } } } 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 a9ad4d2b8f..3b9ebe6028 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 6e442d2a2b..8b3b1e6f1c 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 80583d0de0..36b09bec97 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -115,6 +115,111 @@ pub struct DocInfo { pub xfa: bool, } +/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s +/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA +/// specification alike. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageIcon { + /// 0, the default. + #[default] + Error, + /// 1. + Warning, + /// 2. + Question, + /// 3. + Status, +} + +impl MessageIcon { + /// The icon numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> 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 { @@ -173,7 +278,8 @@ pub struct Outcome { pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, - 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 38cebf54ce..2d3877efe3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() { assert_eq!(o.value, "string:number"); } +/// A validate script refuses a value with `event.rc = false` and says why in a message. #[test] fn validate_scripts_reject_with_rc_and_alert() { let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11")); assert!(!o.rc); - assert_eq!(o.alerts, ["Too many"]); + assert_eq!(o.alerts, [Message::plain("Too many")]); let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3")); assert!(o.rc); } @@ -251,9 +252,11 @@ fn kept_bytes(o: &Outcome) -> usize { Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), _ => 0, }; - o.console.iter().chain(&o.alerts).map(String::len).sum::() + 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", "")); @@ -275,11 +278,99 @@ fn console_alerts_and_requests_are_capped_in_count_and_bytes() { assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); - assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097)); assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); } +/// The message `app.alert(args)` shows, from a button's Mouse Up script. +fn alerted(args: &str) -> Message { + let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None, "{args}"); + assert_eq!(o.alerts.len(), 1, "{args}"); + o.alerts.into_iter().next().unwrap_or_default() +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for +/// Acrobat API Reference numbers, and the title. +#[test] +fn app_alert_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + // Only the message: no title, the Error icon and the OK button, the defaults. + assert_eq!(alerted("'Saved'"), Message::plain("Saved")); + assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") }); +} + +/// The single-object form names the same parameters, in any order, and reads nothing else. +#[test] +fn app_alert_reads_the_object_form() { + let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }"); + assert_eq!( + m, + Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo } + ); + assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done")); +} + +/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a +/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`. +#[test] +fn objects_that_name_no_parameter_are_the_message_itself() { + assert_eq!(alerted("new String('Saved')"), Message::plain("Saved")); + let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}"); + let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}"); +} + +/// An icon or button number that isn't one of the four (fractional, negative, too large, not a +/// number, or a value that can't be converted) is the default, and the message still shows; +/// a number given as text counts. +#[test] +fn app_alert_falls_back_on_bad_icon_and_button_numbers() { + for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] { + let m = alerted(&format!("'Hi', {bad}, {bad}, ''")); + assert_eq!(m, Message::plain("Hi"), "{bad}"); + } + assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") }); + // Every call still answers OK (1) until answers are asked for (#4291). + let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.value, "1"); +} + +/// A title is cut to a message's length like the text, and both are charged to the run's byte +/// budget: titles can't carry more text out than messages can. +#[test] +fn app_alert_titles_are_clipped_and_charged() { + use crate::host::Host; + let m = alerted("'Hi', 0, 0, new Array(100000).join('t')"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len)); + // The title's bytes count against the budget with the text's, and a message the budget + // has no room for is left out and counted. + let mut h = Host { + fields: Arc::new(Vec::new()), + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc(), + kept: 0, + dropped: 0, + }; + h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") }); + assert_eq!(h.kept, "Saved".len() + "Order form".len()); + h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len(); + h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") }); + assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out"); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); @@ -435,8 +526,8 @@ fn scripts_cannot_compile_code_from_strings() { } mod xfa_model { - use crate::Limits; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { XfaNode { @@ -670,6 +761,8 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("4/1")); } + /// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep + /// their details. #[test] fn host_layout_event_and_app_calls_become_effects() { let o = go( @@ -681,7 +774,12 @@ mod xfa_model { assert_eq!( o.effects, vec![ - XfaEffect::MessageBox("Hi".into()), + XfaEffect::MessageBox(Message { + text: "Hi".into(), + title: Some("T".into()), + icon: MessageIcon::Status, + buttons: MessageButtons::OkCancel + }), XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::SaveAs, @@ -726,6 +824,7 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("1002")); } + /// A value set again replaces the earlier one, and hostile loops end at the caps. #[test] fn values_set_again_merge_and_hostile_loops_are_capped() { // A calculate that sets its own value in a loop leaves one effect: the last value. @@ -747,7 +846,56 @@ mod xfa_model { assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes); // Long messages are cut. let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); - assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); + assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097)); + } + + /// The message box `script` showed from a button's click script. + fn boxed(script: &str) -> Message { + let o = go(script, "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None, "{script}"); + match o.effects.as_slice() { + [XfaEffect::MessageBox(m)] => m.clone(), + other => panic!("{script}: {other:?}"), + } + } + + /// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the + /// XFA specification numbers, and the title. + #[test] + fn message_box_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved")); + // A bad number is the default; the message still shows. + assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi")); + assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi")); + let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)); + // Answers are always OK for now (#4291). + let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click"); + assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects); + } + + /// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`) + /// and its object form, not `messageBox`'s. + #[test] + fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() { + let m = boxed("app.alert('Sure?', 1, 3, 'Order form')"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel } + ); + let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel } + ); } /// Past the byte budget, adding or removing a row is left out of the snapshot as well as the @@ -818,6 +966,7 @@ mod xfa_model { assert_eq!(names, 1); } + /// Time the engine takes to start is not taken from the script's own time. #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -852,9 +1001,9 @@ mod xfa_model { mod formcalc { use super::xfa_model::form; - use crate::Limits; use crate::formcalc::run_formcalc; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; @@ -1165,6 +1314,7 @@ s"# assert!(o.error.unwrap().contains("endif")); } + /// FormCalc resolves form objects, assigns to them and records the effects. #[test] fn form_objects_resolve_assign_and_record_effects() { assert_formcalc_reads_fields_and_rows(); @@ -1242,7 +1392,7 @@ table._row.count"#, XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() }, XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() }, - XfaEffect::MessageBox("Total 17.5".into()), + XfaEffect::MessageBox(Message::plain("Total 17.5")), XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] @@ -1311,6 +1461,7 @@ $host.gotoURL("https://example.org")"#, assert_eq!(run(&script).result.as_deref(), Some("2")); } + /// Hostile FormCalc scripts end with an error and bounded output. #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { assert_hostile_control_flow_is_bounded(); @@ -1361,7 +1512,7 @@ $host.gotoURL("https://example.org")"#, ); assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); - assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len()); let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); @@ -1467,6 +1618,27 @@ $host.gotoURL("https://example.org")"#, assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + /// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button + /// set and the title, as the JavaScript one does. + #[test] + fn formcalc_message_box_keeps_its_title_icon_and_buttons() { + let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click"); + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})")); + let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }; + assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}"); + } + } + // Missing or bad numbers are the defaults; a long title is cut like the message. + assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + let o = fc("$host.messageBox(\"Hi\", Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097))); + } + fn run_hostile_formcalc(script: &str) -> XfaOutcome { let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; let doc = XfaDoc::default(); @@ -1884,5 +2056,5 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() { let o = go(XFA_CHECK, &Event::doc("Open")); assert_eq!(o.error, None); assert_eq!(o.value, "undefined number 3.5"); - assert_eq!(o.alerts, ["old viewer"]); + assert_eq!(o.alerts, [Message::plain("old viewer")]); } 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 54508e6cab..971cf12fc1 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -9,17 +9,28 @@ use super::{ // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── -/// Records effect `e` on the host, as [`XHost::push`] does. +/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does. fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { host(ctx)?.borrow_mut().push(e); Ok(()) } -/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and -/// returns 1. +/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title, +/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - 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)) } @@ -211,7 +222,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current ctx.register_global_property(js_string!("event"), ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) 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 83b2d7d149..04ec0f8349 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)] @@ -95,7 +95,8 @@ pub enum XfaEffect { RemoveInstance { som: String, }, - MessageBox(String), + /// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script. + MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. @@ -209,7 +210,8 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), - XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::MessageBox(m) => m.bytes(), + XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8925a0dca1..5de2485857 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/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/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 4296487e4c..b8f5d51cc6 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 { @@ -81,11 +96,47 @@ impl PdfCraftApp { Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } - if let Some(a) = out.alerts.last() { - self.notify(a.clone()); + self.show_script_messages(out.alerts); + } + + /// Show form script messages `messages` in order, each in the toast with its title and icon. + /// The first takes the toast at once when it is free; otherwise it waits, like the rest, for + /// what the toast shows (another message, or a notice such as the run's own submit request) + /// to go ([`Self::next_script_message`]). + pub fn show_script_messages(&mut self, messages: Vec) { + let room = MAX_WAITING_MESSAGES.saturating_sub(self.script_messages.len()); + let left_out = u64::try_from(messages.len().saturating_sub(room)).unwrap_or(u64::MAX); + self.script_messages_dropped = self.script_messages_dropped.saturating_add(left_out); + self.script_messages.extend(messages.into_iter().take(room)); + if self.toast.is_none() { + self.next_script_message(); } } + /// Put the next waiting script message in the toast, or, once none waits, a notice of how many + /// the queue had no room for. False when there is neither. + pub fn next_script_message(&mut self) -> bool { + let Some(m) = self.script_messages.pop_front() else { + // Every message the queue held has been shown: say how many it had no room for. + if self.script_messages_dropped == 0 { + return false; + } + let n = std::mem::take(&mut self.script_messages_dropped); + self.notify(crate::i18n::trn( + crate::i18n::current(), + n, + "{n} more message from the form's scripts was left out.", + "{n} more messages from the form's scripts were left out.", + )); + // Kept with the toast until it has been shown its time (the toast's own expiry). + self.toast_dropped = n; + return true; + }; + self.toast = Some((m.text.clone(), 0.0)); + self.toast_script = Some(m); + true + } + /// Run a push button's JavaScript (its Mouse Up action). pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) { // A script reads the fields: include what's still being typed in one (#166). 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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2a17a9f02e..d9fbbd1291 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"