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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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 4ce68f517b072ee96bf83319e2ccced1a5971b4d Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:03:08 +0200 Subject: [PATCH 07/35] M4: New bookmarks from text styles, for headings that aren't tagged (#1693) Bookmark options > New bookmarks from text styles... lists the styles the document's text is set in (font without its subset tag, the size shown, bold, italic, how many lines, a sample), the largest first, and takes one style per bookmark level. Every line in a level's style becomes a bookmark of that level under a new first "Untitled" bookmark, as from structure; a heading set on several rows is one bookmark, and each goes to the top of its heading. The detection lives in engine::text_style_bookmarks on top of edit::text_lines, reading each page top to bottom then left to right, so two headings on one row stay two. text_styles and bookmark_from_text_styles reach it headlessly, and the command bookmark.from_text_styles opens the dialog. OutlineEntry gets an optional top for the /XYZ destination; structure bookmarks keep theirs. --- crates/automation/src/lib.rs | 58 ++++++ crates/automation/src/tools.rs | 33 ++++ crates/automation/tests/it/automation.rs | 72 ++++++++ crates/engine/src/commands.rs | 2 + crates/engine/src/lib.rs | 27 ++- crates/engine/src/tests.rs | 83 +++++++++ crates/engine/src/text_style_bookmarks.rs | 211 ++++++++++++++++++++++ crates/organize/src/outline.rs | 17 +- crates/organize/src/tests.rs | 2 +- crates/ui-egui/src/bookmark_styles_ui.rs | 127 +++++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/editing.rs | 5 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels.rs | 6 + crates/ui-egui/tests/it/bookmarks.rs | 91 ++++++++++ parity/acrobat-features.toml | 12 ++ 17 files changed, 746 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/text_style_bookmarks.rs create mode 100644 crates/ui-egui/src/bookmark_styles_ui.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e94354021f..30caf10ddc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -421,6 +421,34 @@ impl Automation { out["next"] = listing["next"].clone(); out } + "text_styles" => { + // The samples are the document's text, as text_lines is. + let doc = self.doc_to_copy(&a)?; + let styles: Vec = doc + .text_styles() + .into_iter() + .map(|u| { + json!({ + "font": u.style.font, + "size": u.style.size, + "bold": u.style.bold, + "italic": u.style.italic, + "lines": u.lines, + "pages": u.pages, + "sample": u.sample, + }) + }) + .collect(); + json!({ "styles": styles }) + } + "bookmark_from_text_styles" => { + let levels = text_style_levels(&a)?; + let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } "doc_protect" => self.doc_protect(&a)?, "page_replace" => { let pages = self.pages(&a, "pages")?; @@ -2422,6 +2450,36 @@ fn parse_style_ranges(value: Option<&Value>) -> Result Result> { + let levels = + a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?; + if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS { + return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles"))); + } + levels + .iter() + .enumerate() + .map(|(i, v)| { + let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}")); + let font = + v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?; + let size = + v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?; + let flag = |key: &str| match v.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(&format!("{key} must be true or false"))), + }; + Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? }) + }) + .collect() +} + /// Typed access to validated arguments. struct Args<'a>(&'a Value); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1bfcc77984..7785f84bc2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -344,6 +344,39 @@ pub fn tools() -> Vec { "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", ) .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "text_styles", + "List text styles", + "The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.", + ) + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "bookmark_from_text_styles", + "New bookmarks from text styles", + "Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "levels": { + "type": "array", + "minItems": 1, + "maxItems": 9, + "items": { + "type": "object", + "properties": { + "font": { "type": "string", "minLength": 1 }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }, + "required": ["font", "size"] + } + } + }), + &["doc", "levels"], + )), t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( json!({ "doc": doc(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9049299702..ca7d6b19cd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -503,6 +503,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() { assert_eq!(result["status"], "passed"); } +/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18, +/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693). +fn styled_headings_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()), + format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the +/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why +/// when it can't. +#[test] +fn bookmarks_from_text_styles_through_tools() { + let dir = workdir("text-style-bookmarks"); + std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone(); + assert_eq!( + styles, + json!([ + { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" }, + { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" }, + { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" }, + ]) + ); + // The styles as text_styles gave them, the chapters first. + let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]); + let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels })); + let top = &r["bookmarks"][0]; + assert_eq!(top["title"], "Untitled"); + let titles: Vec<(&str, u64)> = + top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect(); + assert_eq!(titles, [("Introduction", 1), ("Results", 2)]); + assert_eq!(top["children"][0]["children"][0]["title"], "Background"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles"); + // What can't be done says why. + let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels })); + assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size"))); + assert!( + matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold")) + ); + let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }])); + assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}"); + let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect(); + assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_)))); +} + /// A one-page PDF whose form holds one text field named `name`. fn form_pdf(name: &str) -> Vec { let objs: Vec = vec![ diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4ebfecafc7..25bf76a944 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"), + // Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693). + c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"), c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"), c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aa71d82fef..12915bc3ce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod signature_scan; mod stamp_preview; pub mod text_fonts; mod text_preview; +pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; @@ -619,6 +620,12 @@ impl Document { self.generation } + /// The styles the document's text is set in, the largest first, for bookmarks from text + /// styles (#1693). + pub fn text_styles(&self) -> Vec { + self.editor.as_ref().map(|e| text_style_bookmarks::styles(&e.cos)).unwrap_or_default() + } + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). pub fn text_lines(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() @@ -1488,6 +1495,11 @@ pub enum Edit { /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, + /// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests + /// under it, …), under a new first "Untitled" bookmark (#1693; see [`text_style_bookmarks`]). + BookmarksFromTextStyles { + levels: Vec, + }, /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. NumberPages { from: usize, @@ -2015,6 +2027,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Colour", "Style", "New bookmarks from structure", + "New bookmarks from text styles", "Number pages", "Measure distance", "Measure perimeter", @@ -2162,6 +2175,7 @@ impl Edit { Edit::SetBookmarkColor { .. } => Label::of("Colour"), Edit::SetBookmarkStyle { .. } => Label::of("Style"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), Edit::NumberPages { .. } => Label::of("Number pages"), // What was measured is part of the wording too, for the same reason. Edit::AddMeasurement(m) => Label::of(match m.kind { @@ -2334,6 +2348,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure + | Edit::BookmarksFromTextStyles { .. } | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -2586,10 +2601,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None }) .collect(); pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; } + Edit::BookmarksFromTextStyles { levels } => { + if levels.is_empty() { + return Err(EditError::Invalid("choose a text style for at least one bookmark level".into())); + } + let entries = text_style_bookmarks::headings(doc, levels); + if entries.is_empty() { + return Err(EditError::Invalid("no text is set in the chosen styles".into())); + } + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, Edit::AddMeasurement(m) => { measure::add(doc, m, &cx.meta())?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dc76159d14..0458cac519 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1846,6 +1846,89 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Three pages whose headings are set apart only by how they look (#1693): chapters in +/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title +/// runs over two lines, its bold font is another subset of the same font, and one section is set +/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row. +fn styled_headings_fixture() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET"; + let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \ + BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \ + BT /FB 14 Tf 72 600 Td (Section C) Tj ET"; + // Two sections on one row, the right one drawn first: read from the left. + let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET"; + let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>"; + pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>")) + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>") + .stream("", page1) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .stream("", page2) + .obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>") + .stream("", page3) + .build() +} + +/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones: +/// chapters at level 1, sections nested under the chapter before them. A two-line title is one +/// bookmark; a subset of the same font and a size set by the text matrix count as the style. +/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused. +#[test] +fn bookmarks_from_text_styles_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let listed: Vec<(String, f64, bool, usize, usize)> = + styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect(); + assert_eq!( + listed, + [ + ("Helvetica-Bold".to_string(), 18.0, true, 3, 2), + ("Helvetica-Bold".to_string(), 14.0, true, 5, 3), + ("Helvetica".to_string(), 11.0, false, 3, 2), + ] + ); + assert_eq!(styles[0].sample, "Chapter One"); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap(); + let outline = s.get(id).unwrap().info.outline.clone(); + assert_eq!( + outline_titles(&outline), + ["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"] + ); + let first = &outline[0].children[0]; + match first.view { + pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => { + assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}") + } + ref v => panic!("{v:?}"), + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + // One level is enough; the sections are then left out. + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]); + s.undo(id).unwrap(); + // Nothing chosen, or a style no text is in: refused, nothing changes. + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err(); + assert!(err.to_string().contains("at least one bookmark level"), "{err}"); + let unused = pdfcraft_engine_style(§ion, 30.0); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err(); + assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} + +/// `style` at another size. +fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { + text_style_bookmarks::TextStyle { size, ..style.clone() } +} + #[test] fn number_pages_shows_in_the_viewer_and_undoes() { let (mut s, id) = session_with(5); diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs new file mode 100644 index 0000000000..937752fa4c --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,211 @@ +//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how +//! they look (chapter titles in one font at one size, sections in another), not tagged as +//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the +//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level. +//! +//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are +//! subsets of one font), the size the text is shown at, and whether it is bold and italic. +//! Lines are taken in reading order down each page: top to bottom, then left to right. + +use pdfcraft_cos::Document; +use pdfcraft_organize::OutlineEntry; + +/// How a line of text looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// The font's name, without a subset tag. + pub font: String, + /// The size the text is shown at, in points (rounded to a tenth). + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +impl TextStyle { + /// Whether `other` is this style. Sizes within a quarter point match, as the same heading + /// size is often written with rounding differences (12 and 11.96). + fn matches(&self, other: &TextStyle) -> bool { + self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE + } +} + +/// How far apart two sizes may be and still be one style. +const SIZE_TOLERANCE: f64 = 0.25; + +/// A style and how much text is set in it. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyleUse { + pub style: TextStyle, + /// Lines in this style. + pub lines: usize, + /// Pages they are on. + pub pages: usize, + /// The first line in it, cut to [`SAMPLE_CHARS`] characters. + pub sample: String, +} + +/// The most pages read: a longer document is listed and searched by its first pages. +pub const MAX_PAGES: usize = 5000; +/// The most bookmarks made at once. +pub const MAX_BOOKMARKS: usize = 10_000; +/// The longest bookmark title kept, in characters (a heading run on into a paragraph). +const MAX_TITLE_CHARS: usize = 300; +/// The longest sample shown for a style. +const SAMPLE_CHARS: usize = 60; +/// The most styles listed, the largest first. +const MAX_STYLES: usize = 200; + +/// One line of text in reading order. +struct Line { + page: usize, + text: String, + style: TextStyle, + /// Top, bottom and left of its box in user space. + top: f64, + bottom: f64, + left: f64, +} + +/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). +fn without_subset_tag(name: &str) -> &str { + match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest, + _ => name, + } +} + +/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that +/// is only whitespace, or whose size can't be read, is left out. +fn lines(doc: &Document) -> Vec { + let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES); + let mut out = Vec::new(); + for page in 0..pages { + let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue }; + let mut on_page: Vec = found + .into_iter() + .filter_map(|l| { + let text = l.text.split_whitespace().collect::>().join(" "); + // `size` is the size the line is shown at in user space (the `Tf` size times the + // text rendering matrix), so `1 Tf` with a 14× text matrix is 14. + let size = l.size; + let [x0, y0, x1, y1] = l.rect; + let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite()); + ok.then(|| Line { + page, + text, + style: TextStyle { + font: without_subset_tag(&l.base_font).to_string(), + size: (size * 10.0).round() / 10.0, + bold: l.bold, + italic: l.italic, + }, + top: y0.max(y1), + bottom: y0.min(y1), + left: x0.min(x1), + }) + }) + .collect(); + // Down the page; along each row from the left. Boxes of one row differ by a fraction + // of a point, so rows are compared at a tenth of a point. + on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left))); + out.extend(on_page); + } + out +} + +/// The styles `doc`'s text is set in, the largest size first (then the most used): headings +/// are usually larger than the text around them. +pub fn styles(doc: &Document) -> Vec { + let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + for line in lines(doc) { + match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + Some((u, last_page)) => { + u.lines = u.lines.saturating_add(1); + if *last_page != Some(line.page) { + u.pages = u.pages.saturating_add(1); + *last_page = Some(line.page); + } + } + None => { + let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); + } + } + } + let mut uses: Vec = uses.into_iter().map(|(u, _)| u).collect(); + uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines))); + uses.truncate(MAX_STYLES); + uses +} + +/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1 +/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A +/// heading set on several lines (one after another in the same style, close below each other) +/// is one bookmark. Each goes to the top of its heading. +pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { + let mut entries: Vec = Vec::new(); + // The heading the previous line belonged to: its level, and the top and bottom of its last + // line. + let mut open: Option<(usize, f64, f64)> = None; + for line in lines(doc) { + let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { + open = None; + continue; + }; + // A heading goes on to the next row down, close below its last line. A line on the same + // row (two headings side by side) is a heading of its own. + let continues = open.is_some_and(|(l, top, bottom)| { + let size = line.style.size; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + }) && entries.last().is_some_and(|e| e.page == line.page); + if continues { + if let Some(e) = entries.last_mut() + && e.title.chars().count() < MAX_TITLE_CHARS + { + e.title.push(' '); + e.title.push_str(&line.text); + e.title = e.title.chars().take(MAX_TITLE_CHARS).collect(); + } + } else { + if entries.len() >= MAX_BOOKMARKS { + break; + } + // The level counts from 1; the position in `levels` is at most its length, which a + // caller keeps small (a few levels), so it fits a u8. + let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX); + entries.push(OutlineEntry { + level, + title: line.text.chars().take(MAX_TITLE_CHARS).collect(), + page: line.page, + element: None, + top: Some(line.top), + }); + } + open = Some((level, line.top, line.bottom)); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subset_tags_are_left_out_of_the_font_name() { + assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters"); + assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters"); + assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag"); + assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x"); + } + + #[test] + fn styles_match_within_a_quarter_point() { + let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false }; + assert!(s(12.0).matches(&s(11.8))); + assert!(!s(12.0).matches(&s(11.7))); + assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) })); + assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) })); + } +} diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index cc1bbb0884..4e90c8eab6 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -509,14 +509,10 @@ fn destination_at(doc: &Document, page: usize, left: Option, top: Option Object { - go_to_at(page, None, None) -} - /// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the -/// reader's zoom; a coordinate that is left out keeps the reader's position. +/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither, +/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a +/// new bookmark when the view has no specific position). fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) @@ -560,7 +556,7 @@ pub fn add_bookmark_at( Ok(path) } -/// One bookmark of a generated tree (New Bookmarks from Structure). +/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles). #[derive(Clone, Debug, PartialEq)] pub struct OutlineEntry { /// 1 = directly under the new parent; deeper levels nest under the last shallower entry. @@ -569,6 +565,9 @@ pub struct OutlineEntry { pub page: usize, /// The structure element it stands for (`/SE`). pub element: Option, + /// Where on the page it goes: this height (user space) at the top of the window, at the + /// reader's zoom. `None` goes to the page, keeping the reader's position. + pub top: Option, } /// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by @@ -592,7 +591,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl for (e, page) in entries.iter().zip(targets) { let mut d = Dict::new(); d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title))); - d.set(b"Dest".to_vec(), go_to(page)); + d.set(b"Dest".to_vec(), go_to_at(page, None, e.top)); if let Some(se) = e.element { d.set(b"SE".to_vec(), Object::Ref(se)); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1ba8861cbe..fb9d03a7fe 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1789,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() { fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { let mut d = doc_a(); crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap(); - let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element }; + let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None }; let se = ObjRef::new(4242, 0); let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)]; assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]); diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs new file mode 100644 index 0000000000..718e582f70 --- /dev/null +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -0,0 +1,127 @@ +//! Bookmarks ▸ New bookmarks from text styles (#1693): for documents whose headings aren't +//! tagged, a style per bookmark level (the chapters' font and size for level 1, the sections' +//! for level 2, …), and every line set in it becomes a bookmark of that level +//! ([`pdfcraft_engine::text_style_bookmarks`]). + +use pdfcraft_engine::text_style_bookmarks::{TextStyle, TextStyleUse}; +use pdfcraft_engine::{DocId, Edit}; + +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The levels offered: deeper outlines are rare, and each level needs a style of its own. +const LEVELS: usize = 4; + +/// What the dialog shows and what has been chosen in it. +#[derive(Debug, Default)] +pub struct TextStyleDraft { + /// The document the styles were read from. + pub doc: Option, + /// Its text styles, the largest first. + pub styles: Vec, + /// The style (an index into `styles`) for each level, level 1 first; a level without one + /// ends the list. + pub levels: [Option; LEVELS], +} + +impl TextStyleDraft { + /// The styles for the chosen levels, level 1 first, up to the first level left without one. + pub fn chosen(&self) -> Vec { + self.levels.iter().map_while(|l| l.and_then(|i| self.styles.get(i)).map(|u| u.style.clone())).collect() + } +} + +impl PdfCraftApp { + /// Open New bookmarks from text styles for document `id`: its styles are read now, and the + /// largest is offered for level 1 when there is more than one (headings are larger than the + /// text around them; a single style has no headings to tell apart). + pub fn open_text_style_bookmarks(&mut self, id: DocId) { + let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); + let first = (styles.len() > 1).then_some(0); + self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] }; + self.dialog = Some(crate::Dialog::BookmarksFromTextStyles); + } +} + +/// How a style is named in the lists: font, size, bold or italic, how many lines, and a sample. +fn style_label(u: &TextStyleUse) -> String { + let mut s = crate::i18n::fmt(tl!("{font}, {size} pt"), &[("font", &u.style.font), ("size", &format!("{}", u.style.size))]); + if u.style.bold { + s.push_str(", "); + s.push_str(tl!("bold")); + } + if u.style.italic { + s.push_str(", "); + s.push_str(tl!("italic")); + } + let lines = if u.lines == 1 { tl!("1 line").to_string() } else { crate::i18n::fmt(tl!("{n} lines"), &[("n", &u.lines.to_string())]) }; + let sample: String = u.sample.chars().take(40).collect(); + let more = if sample.chars().count() < u.sample.chars().count() { "…" } else { "" }; + format!("{s} · “{sample}{more}” ({lines})") +} + +/// The dialog's contents and its buttons. Returns whether it closes; Create makes the +/// bookmarks and shows them in the Bookmarks panel. +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new(tl!("New bookmarks from text styles")).font(theme::semibold(18.0))); + ui.add_space(6.0); + let d = &mut app.text_style_draft; + let id = d.doc; + if d.styles.is_empty() { + ui.label(tl!("This document has no text to make bookmarks from.")); + ui.add_space(12.0); + return widgets::pill_button(ui, tl!("Close"), true).clicked(); + } + ui.label( + egui::RichText::new(tl!("Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark.")) + .color(t.text_muted), + ); + ui.add_space(8.0); + let none = tl!("None"); + egui::Grid::new("text_style_levels").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for level in 0..LEVELS { + // A level takes a style only once the one above it has one. + let open = level == 0 || d.levels.get(level - 1).copied().flatten().is_some(); + let label = ui.label(crate::i18n::fmt(tl!("Level {n}"), &[("n", &(level + 1).to_string())])); + ui.add_enabled_ui(open, |ui| { + let Some(slot) = d.levels.get_mut(level) else { return }; + let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label); + egui::ComboBox::from_id_salt(("text_style_level", level)) + .selected_text(shown) + .width(460.0) + .show_ui(ui, |ui| { + ui.selectable_value(slot, None, none); + for (i, u) in d.styles.iter().enumerate() { + ui.selectable_value(slot, Some(i), style_label(u)); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + } + }); + // A level left without a style ends the list: the ones below it don't count. + if let Some(gap) = d.levels.iter().position(Option::is_none) { + d.levels.iter_mut().skip(gap).for_each(|l| *l = None); + } + let levels = d.chosen(); + ui.add_space(16.0); + let mut close = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(!levels.is_empty(), |ui| widgets::pill_button(ui, tl!("Create"), true)).inner.clicked() { + close = true; + // The document the styles came from, even if another tab is active by now. + if let Some(i) = id.and_then(|id| app.views.iter().position(|v| v.id == id)) { + app.active = Some(i); + if app.apply_edit(Edit::BookmarksFromTextStyles { levels }) { + app.right = Some(crate::RightPanel::Bookmarks); + } + } + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + close +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c0e12d5d09..b10e16b8c8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -219,6 +219,7 @@ impl PdfCraftApp { } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), + "bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles), "edit.undo" => self.undo(), "edit.redo" => self.redo(), "edit.find" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 086dd2346c..f4100f2f05 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1129,6 +1129,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { close = apply || cancel; return; } + Dialog::BookmarksFromTextStyles => { + close = crate::bookmark_styles_ui::body(ui, app, &t); + return; + } Dialog::NumberPages => { use pdfcraft_engine::LabelStyle as L; ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4f25d6df21..e23123c3bd 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1228,6 +1228,11 @@ impl PdfCraftApp { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure } + // A dialog chooses the styles; it makes the edit. + A::FromTextStyles => { + self.open_text_style_bookmarks(id); + return; + } }; self.apply_edit(edit); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c9..ec2b596edb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,7 @@ pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep}; mod autoscroll; mod bidi; +pub mod bookmark_styles_ui; pub mod bulk_fields; mod dialogs; mod edit_text_ui; @@ -295,6 +296,8 @@ pub enum Dialog { NumberPages, /// Protect Using Password. Protect, + /// Bookmarks ▸ New bookmarks from text styles (#1693). + BookmarksFromTextStyles, /// Set Page Boxes (crop, trim, bleed, art, media). PageBoxes, /// Add / Update Header and Footer, Watermark, Background. @@ -937,6 +940,8 @@ pub struct PdfCraftApp { pub(crate) word_count_draft: word_count_ui::Draft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, + /// New bookmarks from text styles dialog state (#1693). + pub text_style_draft: bookmark_styles_ui::TextStyleDraft, /// Set Page Boxes dialog state. pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. @@ -1256,6 +1261,7 @@ impl PdfCraftApp { pending_full_screen: None, pending_remote_link: None, protect_draft: Default::default(), + text_style_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), export_draft: Default::default(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a7820c2d02..61975b275b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -734,6 +734,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromStructure); ui.close(); } + if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() { + bm_action = Some(BmAction::FromTextStyles); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), @@ -1048,6 +1052,8 @@ pub enum BmAction { Outdent(Vec), /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, + /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). + FromTextStyles, } /// Matching titles plus their ancestors, keeping document paths rather than filtered indexes. diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151bb..1019014f64 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -250,6 +250,97 @@ trailer << /Root 1 0 R >> } } +/// Two pages whose chapter and section headings are set apart only by style (#1693). +fn styled_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj +5 0 obj << /Length {} >> +stream +{page1} +endstream +endobj +6 0 obj << /Length {} >> +stream +{page2} +endstream +endobj +7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> endobj +8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj +trailer << /Root 1 0 R >> +%%EOF", + page1.len(), + page2.len() + ) + .into_bytes() +} + +/// #1693: Bookmark options ▸ New bookmarks from text styles… lists the document's styles with a +/// sample, offers the largest for level 1, and Create makes the bookmarks (sections under +/// their chapter) as one undo step. Cancel changes nothing; a document without text says so. +#[test] +fn new_bookmarks_from_text_styles_from_the_bookmark_options() { + let mut h = harness(); + h.state_mut().open_bytes("styled.pdf", None, styled_pdf()).unwrap(); + h.run_steps(3); + let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("New bookmarks from text styles…").click(); + h.run_steps(3); + }; + open(&mut h); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); + assert_eq!(h.state().text_style_draft.levels, [Some(0), None, None, None], "the largest style is offered for level 1"); + // Cancel: nothing made. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().session.get(id).unwrap().info.outline.is_empty()); + // Level 2 from its list: the sections' style. + open(&mut h); + // Each level's list is named after it, for screen readers too. + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Level 2").click(); + h.run_steps(2); + // It lists every style, by font, size, weight and a sample. + h.get_by_label("Helvetica-Bold, 18 pt, bold · “Introduction” (2 lines)"); + h.get_by_label("Helvetica, 11 pt · “Some text” (2 lines)"); + h.run_steps(2); + h.get_by_label("Helvetica-Bold, 14 pt, bold · “Background” (1 line)").click(); + h.run_steps(2); + assert_eq!(h.state().text_style_draft.levels, [Some(0), Some(1), None, None]); + h.get_by_label("Create").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let outline = h.state().session.get(id).unwrap().info.outline.clone(); + assert_eq!(outline.len(), 1); + assert_eq!(outline[0].title, "Untitled"); + let titles: Vec<(&str, Option)> = outline[0].children.iter().map(|o| (o.title.as_str(), o.page)).collect(); + assert_eq!(titles, [("Introduction", Some(0)), ("Results", Some(1))]); + assert_eq!(outline[0].children[0].children[0].title, "Background"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); + // A document with no text: nothing to choose from. + h.state_mut().open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap(); + h.run_steps(3); + open(&mut h); + let message = h.get_by_label("This document has no text to make bookmarks from.").rect(); + // The dialog's Close, below the message (the panel has a Close of its own). + h.get_all_by_label("Close").find(|n| n.rect().top() > message.bottom()).expect("the dialog's Close").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + // The command (Pages menu, command palette) opens it too. + assert!(h.state_mut().execute("bookmark.from_text_styles")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); +} + #[test] fn expanding_and_collapsing_all_bookmarks() { use pdfcraft_engine::Edit; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2c9cf17197..6f87c071e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3378,6 +3378,18 @@ tools = ["bookmark_from_structure"] evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/it/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +[[feature]] +id = "organize.bookmarks-from-text-styles" +title = "Generate bookmarks from text styles" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = ["bookmark.from_text_styles"] +tools = ["text_styles", "bookmark_from_text_styles"] +evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"] +notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From 4c0974777323afcf18dc3208aafbdb94121f2f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:51:53 +0000 Subject: [PATCH 08/35] M4: bookmarks from text styles need copying permission and keep columns apart (#1693) CodeRabbit on #4560: - The bookmark titles are the document's text, so the edit now needs copying permission as well as assembly, and Document::text_styles (the dialog's samples) is empty when copying is withheld; the automation text_styles tool already checked it. - A heading went on to the next row whenever it was close below in the same style, so same-style headings in two columns on staggered rows ran together into one bookmark. A line continues a heading only when it lies under it. - Lines found their style by a scan over every style so far; they are now indexed by font, weight, slant and quarter-point size, so a page with thousands of styles no longer makes the scan quadratic. Regression tests for the first two (each fails without its fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/engine/src/lib.rs | 16 +++++++- crates/engine/src/tests.rs | 46 +++++++++++++++++++++++ crates/engine/src/text_style_bookmarks.rs | 38 ++++++++++++++----- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 12915bc3ce..af4ba5913a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -622,7 +622,11 @@ impl Document { /// The styles the document's text is set in, the largest first, for bookmarks from text /// styles (#1693). + /// Empty when the document doesn't allow copying: the samples are its text. pub fn text_styles(&self) -> Vec { + if !self.allows_copying() { + return Vec::new(); + } self.editor.as_ref().map(|e| text_style_bookmarks::styles(&e.cos)).unwrap_or_default() } @@ -2348,7 +2352,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure - | Edit::BookmarksFromTextStyles { .. } | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -2356,6 +2359,17 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Err(EditError::NotPermitted("page changes")) } } + // Creates bookmarks (assembly), and their titles are the document's text, copied out of it + // (Table 22, bit 5), as an export of it would be. + Edit::BookmarksFromTextStyles { .. } => { + if !p.assemble() { + Err(EditError::NotPermitted("page changes")) + } else if !p.copy() { + Err(EditError::NotPermitted("copying text and images")) + } else { + Ok(()) + } + } Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0458cac519..1e7b3020fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1924,6 +1924,52 @@ fn bookmarks_from_text_styles_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document +/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. +#[test] +fn bookmarks_from_text_styles_need_copying_permission() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying()); + assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied"); + let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false }; + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("copying text and images")); + let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(!s.get(owner).unwrap().text_styles().is_empty()); + assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may"); +} + +/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower, +/// are two bookmarks, not one heading run on across the columns. +#[test] +fn headings_in_two_columns_are_two_bookmarks() { + let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET"; + let bytes = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", page) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .build(); + let mut s = Session::new(); + let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap(); + let style = s.get(id).unwrap().text_styles()[0].style.clone(); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]); +} + /// `style` at another size. fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { text_style_bookmarks::TextStyle { size, ..style.clone() } diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs index 937752fa4c..07a0cacd78 100644 --- a/crates/engine/src/text_style_bookmarks.rs +++ b/crates/engine/src/text_style_bookmarks.rs @@ -60,10 +60,11 @@ struct Line { page: usize, text: String, style: TextStyle, - /// Top, bottom and left of its box in user space. + /// Top, bottom, left and right of its box in user space. top: f64, bottom: f64, left: f64, + right: f64, } /// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). @@ -102,6 +103,7 @@ fn lines(doc: &Document) -> Vec { top: y0.max(y1), bottom: y0.min(y1), left: x0.min(x1), + right: x0.max(x1), }) }) .collect(); @@ -117,8 +119,21 @@ fn lines(doc: &Document) -> Vec { /// are usually larger than the text around them. pub fn styles(doc: &Document) -> Vec { let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + // The styles by font, weight, slant and size in quarter points, so a line is matched against + // the few styles near its size rather than every style found (a page can hold thousands). + // A size matches within a quarter point: the bucket's neighbours are looked at too. + let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec> = std::collections::HashMap::new(); + let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64; for line in lines(doc) { - match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b); + let b = bucket(line.style.size); + let found = [b.saturating_sub(1), b, b.saturating_add(1)] + .into_iter() + .filter_map(|b| index.get(&key(b))) + .flatten() + .copied() + .find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style))); + match found.and_then(|i| uses.get_mut(i)) { Some((u, last_page)) => { u.lines = u.lines.saturating_add(1); if *last_page != Some(line.page) { @@ -128,6 +143,7 @@ pub fn styles(doc: &Document) -> Vec { } None => { let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + index.entry(key(b)).or_default().push(uses.len()); uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); } } @@ -144,19 +160,21 @@ pub fn styles(doc: &Document) -> Vec { /// is one bookmark. Each goes to the top of its heading. pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { let mut entries: Vec = Vec::new(); - // The heading the previous line belonged to: its level, and the top and bottom of its last - // line. - let mut open: Option<(usize, f64, f64)> = None; + // The heading the previous line belonged to: its level, and the top, bottom, left and right + // of its last line. + let mut open: Option<(usize, f64, f64, f64, f64)> = None; for line in lines(doc) { let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { open = None; continue; }; - // A heading goes on to the next row down, close below its last line. A line on the same - // row (two headings side by side) is a heading of its own. - let continues = open.is_some_and(|(l, top, bottom)| { + // A heading goes on to the next row down, close below its last line and under it. A line + // on the same row (two headings side by side), or in another column, is a heading of + // its own. + let continues = open.is_some_and(|(l, top, bottom, left, right)| { let size = line.style.size; - l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + let under = line.left < right && left < line.right; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under }) && entries.last().is_some_and(|e| e.page == line.page); if continues { if let Some(e) = entries.last_mut() @@ -181,7 +199,7 @@ pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { top: Some(line.top), }); } - open = Some((level, line.top, line.bottom)); + open = Some((level, line.top, line.bottom, line.left, line.right)); } entries } From e3662af3a5a770fcf9276ce18babbc5e30378117 Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:56:17 +0100 Subject: [PATCH 09/35] M6: Delete removes one selected radio button, not its whole group (#4609) Prepare Form deletes the selected widget: one radio button of a group or one copy of a duplicated field, as Acrobat does; a field's last widget takes the field with it. The widget leaves its page, the field's /Kids and /CO; a per-widget /Opt loses its entry, and a group that was on only through it turns Off. form_delete_field takes an optional widget. --- crates/automation/src/forms.rs | 9 ++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 21 +++++++ crates/engine/src/lib.rs | 10 ++- crates/forms/src/author.rs | 55 ++++++++++++++-- crates/forms/src/lib.rs | 2 +- crates/forms/src/tests.rs | 80 ++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/prepare.rs | 24 ++++--- crates/ui-egui/tests/it/prepare.rs | 74 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 11 files changed, 263 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 71fbc67b3b..c428620d0f 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -676,7 +676,14 @@ impl Automation { if !self.doc(a)?.form.iter().any(|f| f.name == name) { return Err(failed(format!("there is no field named {name:?} (see form_fields)"))); } - self.apply(a, Edit::DeleteField { name }) + let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) }; + let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len()); + let widget = usize::try_from(widget) + .ok() + .and_then(|w| w.checked_sub(1)) + .filter(|w| *w < count) + .ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?; + self.apply(a, Edit::DeleteWidget { name, widget }) } pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 01c57fdaaa..4fb3d857ac 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -480,9 +480,9 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), - t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") + t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])), t( "redact_mark", "Mark for redaction", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 687dd37a4f..f226170928 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9065,3 +9065,24 @@ fn page_render_draws_stamps_without_an_appearance() { let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); } + +#[test] +fn deleting_one_radio_button_through_tools_keeps_the_group() { + let dir = workdir("delete-radio"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (x, export) in [(20, "S"), (40, "M"), (60, "L")] { + let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export }); + ok(&mut a, "form_add_field", args); + } + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } })); + ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 })); + let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone(); + assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone"); + for widget in [0, 3, -1] { + let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget })); + assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}"); + } + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f11671bf85..84c809092e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1653,6 +1653,12 @@ pub enum Edit { DeleteField { name: String, }, + /// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button + /// of a group, or one copy of a duplicated field. The last widget takes the field with it. + DeleteWidget { + name: String, + widget: usize, + }, /// Page properties ▸ Tab order, for pages (0-based). SetTabOrder { pages: Vec, @@ -2200,7 +2206,7 @@ impl Edit { Edit::ResetForm { .. } => Label::of("Clear form"), Edit::AddField { .. } => Label::of("Add field"), Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"), Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), @@ -2415,6 +2421,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::XfaToStatic | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } + | Edit::DeleteWidget { .. } | Edit::DuplicateField { .. } | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } @@ -2733,6 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } } Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, // As after a script edit: the values the new order gives show at once. diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 873ef3d716..3161209111 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1578,8 +1578,58 @@ pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormEr delete_found_field(doc, f) } +/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the +/// field and its other widgets: one radio button of a group, or one copy of a duplicated field, +/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted +/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was +/// on through the deleted widget alone turns off. +pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.get(widget).cloned() else { + return invalid(format!("{name} has no widget {}", widget.saturating_add(1))); + }; + // A field merged with its widget is that widget's only one. + if f.widgets.len() < 2 || w.obj == f.obj { + return delete_found_field(doc, f); + } + let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + let on_only_here = w.on_state.as_deref().is_some_and(|on| { + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + drop_from_pages(doc, &std::iter::once(w.obj).collect())?; + detach(doc, w.obj)?; + drop_from_calculation_order(doc, |r| r == w.obj)?; + let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut changed = false; + if per_widget_opt + && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && opt.len() == f.widgets.len() + { + let kept: Vec = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect(); + d.set(b"Opt".to_vec(), Object::Array(kept)); + changed = true; + } + if on_only_here && d.get(b"V").is_some() { + d.set(b"V".to_vec(), Object::name("Off")); + changed = true; + } + if changed { + doc.set(f.obj, Object::Dict(d)); + } + Ok(()) +} + fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + drop_from_pages(doc, &widgets)?; + detach(doc, f.obj)?; + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference. +fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet) -> Result<(), FormError> { for p in page_refs(doc) { let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); @@ -1591,10 +1641,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { } } } - detach(doc, f.obj)?; - // Out of the calculation order too: a `/CO` entry would keep the field, its value and its - // widgets reachable for a full save. - drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) + Ok(()) } /// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 2354d69f6b..9fd64d1195 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09c6e39b7a..37c7a6cbf4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4359,3 +4359,83 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and +/// the group's buttons in the calculation order; and a text field `name` with two widgets. +fn radio_group_form() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8 + "<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 12 + ]; + document(&objs) +} + +fn annots(doc: &Document) -> Vec { + let p = page_refs(doc)[0]; + let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap()); + a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect() +} + +#[test] +fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "only Medium is gone"); + assert_eq!(size.value, ["S"], "the group keeps its choice"); + // The per-widget /Opt loses Medium's entry, so the others keep their export values. + assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large"))); + assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page"); + let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap(); + assert!(co.is_empty(), "and the calculation order"); + assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched"); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value); + assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); +} + +#[test] +fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let name = field(&all, "name"); + assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::>(), name.value.clone()), (vec![11], vec!["Ann".to_string()])); + assert_eq!(annots(&doc), [6, 7, 8, 11]); + // The last widget takes the field with it, as deleting the field does. + let mut doc = doc; + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + assert!(!fields(&doc).iter().any(|f| f.name == "name")); + assert_eq!(annots(&doc), [6, 7, 8]); +} + +#[test] +fn deleting_a_widget_that_is_not_there_changes_nothing() { + let mut doc = radio_group_form(); + assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into()))); + assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into()))); + assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX)))); + assert_eq!(field(&fields(&doc), "size").widgets.len(), 3); +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9a13d570d9..d1a168554e 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1075,6 +1075,7 @@ fn is_form_edit(edit: &Edit) -> bool { | Edit::ResetForm { .. } | Edit::AddField { .. } | Edit::DeleteField { .. } + | Edit::DeleteWidget { .. } | Edit::DuplicateField { .. } | Edit::SetFieldProps { .. } | Edit::SetFieldImage { .. } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 9f58f5ea5e..6faa3afe8e 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -597,20 +597,24 @@ pub(crate) fn paint_page( } /// Delete the selected field and every field selected with it (#95), as one undoable step, and -/// clear the selection. +/// clear the selection. Each selection is one widget, so selecting one radio button of a group, +/// or one copy of a duplicated field, deletes only that one, as in Acrobat (#4609); a field's +/// last widget takes the field with it. pub(crate) fn delete_selected(view: &mut DocView) -> Option { - let (first, _) = view.prepare.selected.take()?; - let mut names = vec![first]; - for (name, _) in std::mem::take(&mut view.prepare.also) { - if !names.contains(&name) { - names.push(name); + let first = view.prepare.selected.take()?; + let mut picked = vec![first]; + for w in std::mem::take(&mut view.prepare.also) { + if !picked.contains(&w) { + picked.push(w); } } - if names.len() == 1 { - return names.pop().map(|name| Edit::DeleteField { name }); + // A field's later widgets first, so the indices of the ones still to go don't shift. + picked.sort_by_key(|w| std::cmp::Reverse(w.1)); + if picked.len() == 1 { + return picked.pop().map(|(name, widget)| Edit::DeleteWidget { name, widget }); } - let label = format!("Delete {} fields", names.len()); - Some(Edit::Batch { label, edits: names.into_iter().map(|name| Edit::DeleteField { name }).collect() }) + let label = format!("Delete {} fields", picked.len()); + Some(Edit::Batch { label, edits: picked.into_iter().map(|(name, widget)| Edit::DeleteWidget { name, widget }).collect() }) } /// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat, diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 936b847d82..e121787f62 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1218,3 +1218,77 @@ fn a_loop_has_its_own_card_that_opens_the_formula() { let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab)); assert_eq!(draft, Some(("a".to_owned(), FieldTab::Calculate))); } + +/// A radio group `shirt` of three buttons, S, M and L, with L chosen, while preparing the form. +fn radio_group_harness() -> Harness<'static, PdfCraftApp> { + let mut h = harness(); + for (x, export) in [(20.0, "S"), (60.0, "M"), (100.0, "L")] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [x, 300.0, x + 14.0, 314.0], + kind: pdfcraft_engine::NewField::Radio { group: Some("shirt".into()), export: export.into() }, + name: Some("shirt".into()), + }); + } + h.state_mut() + .apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "shirt".into(), value: pdfcraft_engine::FieldValue::Radio(Some("L".into())) }); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(3); + h +} + +fn buttons(h: &Harness<'static, PdfCraftApp>) -> (Vec, Vec) { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "shirt").expect("the group is still there"); + (f.widgets.iter().filter_map(|w| w.on_state.clone()).collect(), f.value.clone()) +} + +/// #4609: Delete removes the one radio button clicked, not its whole group, as in Acrobat. +#[test] +fn deleting_one_radio_button_keeps_the_rest_of_its_group() { + let mut h = radio_group_harness(); + assert_eq!(buttons(&h).0, ["S", "M", "L"]); + let p = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "shirt").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 1).expect("on screen").center() + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().views[0].prepare.selected, Some(("shirt".into(), 1)), "the middle button is selected"); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(buttons(&h), (vec!["S".into(), "L".into()], vec!["L".into()]), "only M is gone; L stays chosen"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete field")); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(buttons(&h).0, ["S", "M", "L"]); +} + +/// Two buttons of one group selected together both go, whichever was selected first. +#[test] +fn deleting_two_radio_buttons_of_a_group_at_once() { + let mut h = radio_group_harness(); + { + let p = &mut h.state_mut().views[0].prepare; + p.selected = Some(("shirt".into(), 0)); + p.also = vec![("shirt".into(), 2)]; + } + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(buttons(&h), (vec!["M".into()], vec![]), "M is left, and nothing is chosen"); + // Selecting every button deletes the group. + { + let p = &mut h.state_mut().views[0].prepare; + p.selected = Some(("shirt".into(), 0)); + } + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert!(!names(&h).iter().any(|n| n == "shirt")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69c98d076a..44c12380ad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4811,8 +4811,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" +evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::deleting_one_radio_button_keeps_the_rest_of_its_group", "crates/ui-egui/tests/it/prepare.rs::deleting_two_radio_buttons_of_a_group_at_once", "crates/forms/src/tests.rs::deleting_one_radio_button_keeps_the_group_and_its_other_buttons", "crates/forms/src/tests.rs::deleting_the_chosen_radio_button_turns_the_group_off", "crates/forms/src/tests.rs::deleting_one_copy_of_a_field_keeps_the_field_and_its_value", "crates/automation/tests/it/automation.rs::deleting_one_radio_button_through_tools_keeps_the_group", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step; a selected radio button or copy of a duplicated field goes on its own and the rest of its group stays, a field's last widget takes the field, #4609); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] id = "form.add-text-field" From f75af23d4ea06a15b10b3d021de8159bf149eef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:26:51 +0000 Subject: [PATCH 10/35] M6: deleting a radio button turns the group off when no button left shows its choice CodeRabbit on #4631: a twin with the same on-state name that is currently off does not keep the choice visible, so compare the remaining buttons' appearance state (/AS), not their on-state name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/forms/src/author.rs | 4 +++- crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 3161209111..81e7ec9dc4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1593,8 +1593,10 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() return delete_found_field(doc, f); } let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + // Turn the group off when no button left *shows* the deleted one's state: a twin with the same + // on-state name that is currently off does not keep the choice visible. let on_only_here = w.on_state.as_deref().is_some_and(|on| { - f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on)) }); drop_from_pages(doc, &std::iter::once(w.obj).collect())?; detach(doc, w.obj)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 37c7a6cbf4..3e919ec7c5 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4414,6 +4414,28 @@ fn deleting_the_chosen_radio_button_turns_the_group_off() { assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); } +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() { + // Medium shares Small's on-state name but shows Off: once Small is gone, no button shows + // the group's /V, so the group must not keep reporting it. + let mut doc = radio_group_form(); + let twin = ObjRef::new(7, 0); + let blank = Object::Ref(ObjRef::new(12, 0)); + let mut n = Dict::new(); + n.set(b"S".to_vec(), blank.clone()); + n.set(b"Off".to_vec(), blank); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = doc.get(twin).as_dict().cloned().unwrap(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + doc.set(twin, Object::Dict(w)); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); +} + #[test] fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { let mut doc = radio_group_form(); From 9fb07e4ac7ccc4065867f354b8d6d05975c90160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:39:30 +0000 Subject: [PATCH 11/35] M6: deleting the default radio button clears the group's default too CodeRabbit on #4631: with /V already Off, a /DV naming the deleted button's state was kept, so Reset would put back a state no button has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/forms/src/author.rs | 9 +++++++++ crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index eccd9a34e2..8165f56afc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1616,6 +1616,15 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() d.set(b"V".to_vec(), Object::name("Off")); changed = true; } + // The default (`/DV`, what Reset puts back) can't name a state no button has any more. + let dv_only_here = w.on_state.as_deref().is_some_and(|on| { + d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes()) + && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + if dv_only_here { + d.set(b"DV".to_vec(), Object::name("Off")); + changed = true; + } if changed { doc.set(f.obj, Object::Dict(d)); } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f52be5306d..b85aeb726c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4654,6 +4654,28 @@ fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); } +#[test] +fn deleting_the_default_radio_button_clears_the_default_too() { + // The group is off, but Reset would put back Small, which no button has once it is gone. + let mut doc = radio_group_form(); + let size = ObjRef::new(5, 0); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"V".to_vec(), Object::name("Off")); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 0).unwrap(); + let doc = reopen(&doc); + let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()); + assert_eq!(dv, Some(Object::name("Off"))); + // A default another button still has stays. + let mut doc = radio_group_form(); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 1).unwrap(); + assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S"))); +} + #[test] fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { let mut doc = radio_group_form(); From 2b83ae6a7a281e80bc8f8ea205ae36c4c59a46af Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 21:01:58 +0100 Subject: [PATCH 12/35] fix(ui): Update previews the page without the marks it replaces Update Background, Update Watermark and Update Header and Footer previewed the new marks on the page's thumbnail, which still has the marks Update replaces. A pale yellow background updated to light blue showed a muddy teal (the blue multiplied onto the yellow), and a new watermark or header was drawn over the old one. The applied result was right; only the preview was wrong. In Update, the preview now draws the page without its marks of that kind. The engine removes them on a copy of the document (Document::page_without_marks, read-only like the other preview layers), and the dialog renders that page off the UI thread at the preview's size, showing a blank sheet until it arrives. Add, and pages without marks of that kind, keep the thumbnail. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 36 +++++++ crates/engine/src/text_preview.rs | 21 +++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/marks_ui.rs | 79 ++++++++++++++- crates/ui-egui/tests/it/marks.rs | 153 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +-- 7 files changed, 294 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 360517a87c..4c061ba809 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2999,6 +2999,42 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// Update previews its marks on the page without the ones it replaces: a yellow background +/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. +/// The other kinds of mark and the page's own content stay, a page without that kind has nothing +/// to prepare, and preparing changes nothing in the document. +#[test] +fn update_previews_on_the_page_without_the_marks_it_replaces() { + let (mut s, id) = session_with(2); + let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: yellow, replace: false, file: None }).unwrap(); + let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap() + }; + let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count(); + let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); + assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); + + let page = doc.page_without_marks(0, MarkKind::Background).unwrap().expect("page 1 has a background").render_background(1.0).unwrap(); + assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); + assert!(reds(&page) > 50, "the watermark stays"); + assert!(dark(&page) > 10, "the page text stays"); + + let page = doc.page_without_marks(0, MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_background(1.0).unwrap(); + assert_eq!(reds(&page), 0, "without the watermark"); + assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); + + assert!(doc.page_without_marks(1, MarkKind::Background).unwrap().is_none(), "page 2 has none: its thumbnail is the page"); + assert!(doc.page_without_marks(0, MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(doc.page_without_marks(9, MarkKind::Background).is_err(), "no page 10"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); +} + #[test] fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index 290231cf73..d19272fcef 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -1,4 +1,5 @@ -//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and, +//! the same way, the page beneath a dragged added item or the marks an Update replaces. use std::sync::Arc; @@ -90,6 +91,24 @@ impl Document { })? } + /// The page without its marks of `kind`: what Update replaces them on, so its preview doesn't + /// draw the new marks over the old ones (or tint the old background). `None` when the page has + /// none, and its thumbnail already is that page. Read-only, like [`Self::text_edit_preview`]. + pub fn page_without_marks(&self, page: usize, kind: crate::MarkKind) -> Result, String> { + if !self.marks().contains(&kind) { + return Ok(None); + } + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + if pdfcraft_edit::remove_marks(&mut cos, &[page], kind).map_err(|e| e.to_string())? == 0 { + return Ok(None); + } + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(Some(TextEditPreview { page, bytes, config: self.config.clone() })) + })? + } + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, /// so the page under its first place doesn't come along. Read-only, like /// [`Self::added_content_preview`]. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b3437246a0..85b8a11c24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1586,6 +1586,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; + app.unmarked_page = Default::default(); } else { app.dialog = Some(next); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..e7452210bb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -955,6 +955,8 @@ pub struct PdfCraftApp { pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. pub marks_draft: marks_ui::MarksDraft, + /// The Update dialogs' preview page without the marks they replace. + pub(crate) unmarked_page: marks_ui::UnmarkedPage, /// Export dialog settings. pub export_draft: export_ui::ExportDraft, /// A running export's progress. @@ -1279,6 +1281,7 @@ impl PdfCraftApp { protect_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), + unmarked_page: Default::default(), export_draft: Default::default(), export_status: None, signature: None, @@ -2878,7 +2881,7 @@ impl PdfCraftApp { /// `true` while any open document still waits for page renders (used by headless capture). pub fn render_pending(&self) -> bool { - self.views.iter().any(|v| v.render_pending()) + self.views.iter().any(|v| v.render_pending()) || self.unmarked_page.pending() } /// Apply a named view option (`--page 3`, `--panel bookmarks`, `--theme dark`, …). diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 5340cef393..1b6f66c2d1 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -12,6 +12,9 @@ use crate::{PdfCraftApp, icons, widgets}; /// How far a watermark may be moved from the page centre, in points. const MAX_OFFSET: f64 = 20_000.0; +/// The preview page's longer side, in points. +const PREVIEW_SIDE: f32 = 190.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Subset { All, @@ -152,7 +155,7 @@ fn preview( draw: impl FnOnce(&egui::Painter, Rect, f32), ) { let (w, h) = (size.0.max(1.0), size.1.max(1.0)); - let s = (190.0 / w.max(h)) as f32; + let s = (f64::from(PREVIEW_SIDE) / w.max(h)) as f32; let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover()); let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); let painter = ui.painter_at(r); @@ -180,6 +183,69 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } +/// In Update, the page previewed without the marks the dialog replaces. Its thumbnail still has +/// them, so the new marks would be drawn over the old ones, and a new background colour would tint +/// the old one. Rendered off the UI thread like the thumbnail, once per page shown. +#[derive(Default)] +pub(crate) struct UnmarkedPage { + /// The document, its edit generation, the page and the kind of mark taken away. + key: Option<(pdfcraft_engine::DocId, u64, usize, MarkKind)>, + renderer: Option, + texture: Option, + tag: Option, +} + +impl UnmarkedPage { + pub(crate) fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + /// The picture to preview `page` on in place of its thumbnail: `None` when the thumbnail is + /// that page (not Update, or no marks of `kind` on it, or the page can't be prepared), and + /// `Some(None)` while it renders, when the page is a blank sheet. + fn picture( + &mut self, + ctx: &egui::Context, + (doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId), + page: usize, + kind: MarkKind, + replace: bool, + ) -> Option> { + let key = replace.then(|| (id, doc.edit_generation(), page, kind)); + if self.key != key { + *self = Self { key, ..Default::default() }; + if replace { + self.renderer = doc.page_without_marks(page, kind).ok().flatten().map(|p| p.renderer()); + } + } + let renderer = self.renderer.as_ref()?; + // At the preview's size in pixels. + let p = doc.info.pages.get(page)?; + let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // The thumbnail, as before, rather than no page at all. + self.renderer = None; + self.texture = None; + return None; + } + if out.request.tag == tag { + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + self.texture = Some(ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR)); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + Some(self.texture.as_ref().map(|t| t.id())) + } +} + /// The page (0-based) the preview shows: the current one when the range selects it, otherwise the /// first page the range selects (the current one again when it selects none). fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { @@ -378,12 +444,15 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.add_space(8.0); // The preview: the current page (or the page stepped to), or the first one the range selects // if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the - // frame the engine lays the marks out in. + // frame the engine lays the marks out in. Update draws it without the marks it replaces. let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); - let picture = app.views.get_mut(i).and_then(|view| { - view.need_thumbnail(target, true); - view.thumb_id(target) + let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); + let picture = unmarked.unwrap_or_else(|| { + app.views.get_mut(i).and_then(|view| { + view.need_thumbnail(target, true); + view.thumb_id(target) + }) }); let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; let hf = d.hf.clone(); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..e833aa0fae 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -294,6 +294,159 @@ fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() { h.get_by_label("Preview: page 2 of 3"); } +/// Keeps every texture the app uploads, so a test can read what a picture it paints shows. +#[derive(Clone, Default)] +struct Uploads(std::sync::Arc>>>); + +impl egui_kittest::TestRenderer for Uploads { + fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) { + let mut seen = self.0.lock().unwrap(); + for (id, images) in delta.set.drain() { + for d in images.into_iter().filter(|d| d.pos.is_none()) { + let egui::ImageData::Color(image) = d.image; + seen.insert(id, image); + } + } + // Freed textures stay readable: ids are never reused. + delta.free.clear(); + } + + fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result { + Err("this renderer only keeps the uploads".into()) + } +} + +/// The page picture the dialog's preview painted last frame, left of its "Preview: page …" label, +/// once its texture is in: its tint and its image. +fn preview_picture(h: &Harness<'static, PdfCraftApp>, uploads: &Uploads) -> Option<(egui::Color32, std::sync::Arc)> { + let label = h.query_by_label_contains("Preview: page")?.rect(); + h.output().shapes.iter().find_map(|s| { + let egui::Shape::Mesh(m) = &s.shape else { return None }; + let r = m.calc_bounds(); + // The size of the 210-point box left of the label (which is centred down its side), and + // not the stepper's arrow beside the label. + let beside = r.width() > 50.0 + && r.height() <= 215.0 + && r.right() <= label.left() + && label.left() - r.right() < 260.0 + && r.top() <= label.bottom() + && r.bottom() >= label.top(); + if !beside { + return None; + } + let image = uploads.0.lock().unwrap().get(&m.texture_id).cloned()?; + Some((m.vertices.first()?.color, image)) + }) +} + +/// [`preview_picture`], waiting until `ready` accepts it (the page renders off the UI thread). +fn wait_for_preview( + h: &mut Harness<'static, PdfCraftApp>, + uploads: &Uploads, + what: &str, + ready: impl Fn(&egui::ColorImage) -> bool, +) -> (egui::Color32, std::sync::Arc) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + h.step(); + let shown = preview_picture(h, uploads); + if let Some((tint, image)) = shown.clone().filter(|(_, image)| ready(image)) { + return (tint, image); + } + assert!( + std::time::Instant::now() < deadline, + "the preview never showed {what}; last (tint, size, middle of the lower half): {:?}", + shown.map(|(tint, i)| (tint, i.size, at(&i, 0.5, 0.75))) + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// The pixel of `image` at (u, v), from 0 to 1 across and down. +fn at(image: &egui::ColorImage, u: f32, v: f32) -> egui::Color32 { + let [w, h] = image.size; + let (x, y) = (((u * w as f32) as usize).min(w - 1), ((v * h as f32) as usize).min(h - 1)); + image.pixels[y * w + x] +} + +fn count(image: &egui::ColorImage, f: impl Fn(egui::Color32) -> bool) -> usize { + image.pixels.iter().filter(|&&c| f(c)).count() +} + +/// Update draws its preview on the page without the marks it replaces, which the page's thumbnail +/// still has: a pale yellow background updated to light blue previews light blue, not blue +/// multiplied onto the yellow, and a new watermark or header isn't drawn over the old one. The +/// page's content and its other kinds of mark stay. +#[test] +fn update_previews_the_page_without_the_marks_it_replaces() { + let uploads = Uploads::default(); + let pdf = content_fixture(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).renderer(uploads.clone()).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); + app + }); + h.run_steps(4); + let yellow = egui::Color32::from_rgb(255, 255, 153); + let red = |c: egui::Color32| c.r() > 200 && c.g() < 80 && c.b() < 80; + let blue = |c: egui::Color32| c.b() > 150 && c.r() < 100 && c.g() < 100; + let dark = |c: egui::Color32| c.r() < 60 && c.g() < 60 && c.b() < 60; + let apply = |h: &mut Harness<'static, PdfCraftApp>, command: &str, draft: &dyn Fn(&mut pdfcraft_ui_egui::marks::MarksDraft)| { + assert!(h.state_mut().execute(command)); + h.run_steps(2); + draft(&mut h.state_mut().marks_draft); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + }; + // A pale yellow background, a red watermark and a blue header. + apply(&mut h, "edit.background", &|d| d.bg.color = [1.0, 1.0, 0.6]); + apply(&mut h, "edit.watermark", &|d| { + d.wm.text = "OLD".into(); + d.wm.color = [1.0, 0.0, 0.0]; + d.wm.opacity = 1.0; + d.wm.rotation = 0.0; + }); + apply(&mut h, "edit.header_footer", &|d| { + d.hf.text[1] = "OLD".into(); + d.hf.color = [0.0, 0.0, 1.0]; + d.hf.font_size = 40.0; + }); + // Add keeps the marks there, and previews on the thumbnail, which has all three. + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + let all = |i: &egui::ColorImage| at(i, 0.5, 0.75) == yellow && count(i, red) > 200 && count(i, blue) > 40; + wait_for_preview(&mut h, &uploads, "the page with its marks", all); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.background.update")); + h.run_steps(2); + h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; + let (tint, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); + assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); + assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.watermark.update")); + h.run_steps(2); + h.state_mut().marks_draft.wm.text = "NEW".into(); + let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); + assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.header_footer.update")); + h.run_steps(2); + let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); + assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); +} + /// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on /// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on /// the last page. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a0dadf4adc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2423,8 +2423,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded" +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces" [[feature]] id = "edit.header-footer-update-remove" @@ -2483,8 +2483,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there)" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces" [[feature]] id = "edit.watermark-add-file" @@ -2531,8 +2531,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background"] tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name" +evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name; Update previews on the page without the backgrounds it replaces, not tinting the old one" [[feature]] id = "edit.background-update-remove" From 24eb327a809c177f183bd697068fb4d90121c89d Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:57:29 +0200 Subject: [PATCH 13/35] fix(ui): the page sets its cursor once, from the top claim the tool reaches Every page module set egui's cursor itself and the last write of a frame won, so the order of document_area's per-page chain decided the cursor, not what a press there does. The link hover loop ran last and showed the hand over any link for every tool but Fill & Sign, though only the Select tool follows links: a click with the stamp tool placed a stamp, a drag with the rectangle tool drew, and in Edit text a drag from linked words moved the paragraph, all under the hand. The text layer ran after the comments and showed the I-beam over a movable comment lying on text, which a drag there moves. Page modules now claim the cursor instead (src/page_cursor.rs): a layer, an icon and optionally a tip. The layers are the review's priority table, Editor > Handle > Placed > Field > Comment > Link > Text > tool area, with Drag on top for a drag in progress, whose owner names the cursor until the release. document_area resolves the claims once per frame, after the pages, and calls set_cursor_icon once: the claim on the highest layer the active tool reaches wins. Links count only for the Select tool (follow) and the Link tool (edit), comments only for the Select tool; the other layers are claimed only by the modules of the tools that act on them. The claims go into a per-frame collector on DocView, which every page module already takes, so the calls in the dispatch block keep their arguments (camera, content and redact lose a `ui` they took only for the cursor). A link's tip goes with its claim, so it shows only where the link wins: no longer with a stamp, drawing or other tool that does not follow links. A comment's tip is a description instead (comments::describe), shown with any tool beside that tool's cursor unless the winning claim has a tip of its own: Acrobat shows a comment's tooltip with every tool (observed with Select, Hand, Add comment, Highlight, Rectangle and Stamp). It keeps the old exceptions (Fill & Sign, freehand drawing for #429, a comment gesture in progress) and comments. A signed field's validation badge (#1895) says its verdict the same way, with any tool, ranked below a comment's description as it was below the comment's tip; the topmost of overlapping badges still speaks. FieldPointer::Over existed only to keep the text layer from overwriting a field's cursor (#2099); the Field layer above Text does that now, so forms_ui::page_input returns whether a field owns the press. Who acts on a press is unchanged (step 3 of the router). The picture's rotate knob claims too: Grab as a handle, Grabbing as a drag while it turns, which dragging_the_rotate_knob_turns_a_page_image now checks. The contains_pointer checks #2755 put before the cursor in content_ui, edit_text_ui, forms_ui and the text layer go: the pages' pointer has no position where the mode bar or a side panel covers a page, so nothing is claimed there, and #2755's tests pass unchanged. During a press the pages hold, those four now keep their cursor over the chrome too, as the other page modules and a text selection being dragged already did. Un-ignores the four step-2 probes in tests/it/pointer.rs, each seen failing on the previous commit at its cursor assertion, after its press assertions passed. A new test, also seen failing there: a link's tip shows only with its hand. Another pins a comment's tip with every tool beside its cursor, and its exceptions; it was seen failing with comment tips tied to the cursor's winner instead, which shows them with the Select tool only. In tests/it/signing.rs, a signed field's badge says its verdict with the Select tool and with a stamp; it fails when the badge is left out of the resolver, as resolving after the page loop would otherwise do. A new ignored probe pins a step-3 bug: over stacked comments the tip names the one drawn on top, but a click selects the one underneath, because PageCx::hit takes the last in inspection's top-edge order. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_ui.rs | 9 +- crates/ui-egui/src/canvas.rs | 139 +++--------- crates/ui-egui/src/comments.rs | 126 +++++++++-- crates/ui-egui/src/content_ui.rs | 17 +- crates/ui-egui/src/crop.rs | 3 +- crates/ui-egui/src/edit_text_ui.rs | 59 ++--- crates/ui-egui/src/fill_sign.rs | 5 +- crates/ui-egui/src/forms_ui.rs | 41 ++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/link_ui.rs | 6 +- crates/ui-egui/src/measure_ui.rs | 4 +- crates/ui-egui/src/object_ui.rs | 5 +- crates/ui-egui/src/page_cursor.rs | 332 +++++++++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 7 +- crates/ui-egui/src/redact_ui.rs | 4 +- crates/ui-egui/src/sign_ui.rs | 3 +- crates/ui-egui/src/zoom_snap.rs | 3 +- crates/ui-egui/tests/it/editing.rs | 2 + crates/ui-egui/tests/it/pointer.rs | 93 +++++++- crates/ui-egui/tests/it/signing.rs | 56 ++++- parity/acrobat-features.toml | 4 +- 21 files changed, 693 insertions(+), 226 deletions(-) create mode 100644 crates/ui-egui/src/page_cursor.rs diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index d470378070..abaad1ed5c 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -5,6 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,11 +60,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::NotAllowed); } return; } @@ -93,7 +94,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); if let Some(i) = q.grab { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { let (x, y) = xf.screen_to_view(p); if let Some(c) = q.corners.get_mut(i) { @@ -104,7 +105,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p q.grab = None; } } else if near { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4f00aa6bec..1b9dce8285 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; +use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -641,6 +642,8 @@ pub struct DocView { /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. pub(crate) fill_signature_page: Option, pub(crate) signature_drag: crate::signature_drag::SignatureDrag, + /// What the page modules claim for the cursor this frame; `document_area` sets it once. + pub(crate) cursor: crate::page_cursor::PageCursor, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they @@ -947,6 +950,7 @@ impl DocView { fill_text: None, fill_signature_page: None, signature_drag: Default::default(), + cursor: Default::default(), } } @@ -2500,17 +2504,6 @@ impl DocView { } } -/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) -/// show none: they are page content being edited, not comments. -fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { - if a.is_reply() || a.fill_sign { - return None; - } - let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); - let body = a.contents.clone().unwrap_or_default(); - Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) -} - /// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within /// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed /// the page misplaced for a frame and then jumped (zooming out beside a page that fits). @@ -3309,7 +3302,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; - let mut badge_hover: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; @@ -3379,9 +3371,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); } else if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab); } } let origin = resp_rect.min - vec2(0.0, y_shift); @@ -3413,7 +3405,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let mut calc_hover = None; for &i in &visible_pages { - badge_hover = None; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { continue; @@ -3578,7 +3569,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && allowed && let Some(p) = ptr.within(xf.rect) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.clicked() { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); @@ -3602,7 +3593,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && allowed && let Some(p) = ptr.within(xf.rect) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.clicked() { // Centred on the click at its natural size (the engine sizes it). let (vx, vy) = xf.screen_to_view(p); @@ -3657,14 +3648,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); } if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); + crate::camera_ui::page_input(&resp, &xf, &ptr, i, view, can_modify); } if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { view.current = i; open_boxes = true; } - // `over_field`: a form field is under the pointer and has set its cursor. - let (on_field, over_field) = if preparing { + let on_field = if preparing { let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, @@ -3675,18 +3665,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { field_props |= o.properties; field_placed |= o.placed; open_table = open_table.or(o.table); - (o.consumed || field_tool.is_some(), false) + o.consumed || field_tool.is_some() } else { - use crate::forms_ui::FieldPointer; - let field = if tool == QuickTool::Select + tool == QuickTool::Select && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) && !comments::select_grabs(&pcx, view) - { - crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) - } else { - FieldPointer::Away - }; - (field == FieldPointer::Owned, field == FieldPointer::Over) + && crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -3696,7 +3680,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) + crate::redact_ui::page_input(&resp, &xf, &ptr, i, info, over_text, view) }; let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); let on_objects = @@ -3729,7 +3713,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let on_content = !on_objects && editing_content && can_modify && { source_blocked || crate::content_ui::page_input( - ui, &resp, &xf, &ptr, @@ -3845,15 +3828,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Column select (#740): the Column select tool, or Alt (Option on macOS) held // while a drag starts, as in Acrobat. let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); - // A link or a form field under the pointer keeps its own cursor: a check box - // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - // So do the mode bar and side panels over a page scrolled under them (#2755), - // unless a selection is being dragged there. - let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); - if column && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } else if over_text && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + // A press on a link selects nothing (it is the link's), so no cursor here says + // it would. + if column && !over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if over_text && !over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); } // The pages' press, kept when its release came in the same frame (#2100): it // still starts multi-click selection. @@ -4029,16 +4009,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips - // draw above the palette, so none shows while it is open, even beside it. - if !matches!(tool, QuickTool::Fill(_)) - && unobstructed - && let Some(p) = pointer - { + // Links: the hand and the link's target, which count only for the tools that follow + // or edit links (`Layer::reached_by`). Tips draw above the palette, so none shows + // while it is open, even beside it. + if unobstructed && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { let sr = xf.user_rect(info, i, l.rect); if sr.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); let label = match &l.target { LinkTarget::Page(n, _) => { crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) @@ -4048,34 +4025,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { LinkTarget::RemoteGoTo { file, .. } => file.clone(), LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), }; - hover_text = Some((p, label)); + view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label)); if resp.clicked() && tool == QuickTool::Select && !consumed { clicked_link = Some(l.target.clone()); } } } - // Annotation hover shows the comment, as Acrobat's popups do; not while drawing - // freehand, where it would cover the next stroke (#429). - let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); - let gesturing = view.comments.gesture.is_some() || freehand; - // Inspection sorts annotations spatially, but the renderer paints in /Annots - // order. Keep the shared list intact and choose the last eligible raw index. - if hover_text.is_none() - && !gesturing - && let Some((_, text)) = info - .annotations - .iter() - .filter(|a| a.page == i) - .filter(|a| { - !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) - }) - .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) - .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) - .max_by_key(|(index, _)| *index) - { - hover_text = Some((p, text)); - } } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); for (mp, mr, mc) in &view.compare_marks { if *mp == i { let sr = xf.user_rect(info, i, *mr).expand(1.5); @@ -4120,21 +4078,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); let mark = Rect::from_center_size(sr.center(), vec2(side, side)); icons::paint(ui, mark, icon, side * 0.64, color); - if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { - badge_hover = Some((p, badge.summary.to_string())); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, + // with any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); } } - // Overlapping widgets paint in order, so the topmost badge owns the hover; an - // earlier link or annotation hover still wins over any badge. - if hover_text.is_none() { - hover_text = badge_hover.take(); - } } if calc_order { view.calc_order.set_hover_page(calc_hover); let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } + // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. + hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole @@ -6044,36 +6001,6 @@ mod tests { } } - fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation { - pdfcraft_render::Annotation { - page: 0, - subtype: "FreeText".into(), - author: None, - contents: Some("Hello".into()), - modified: None, - name: None, - in_reply_to: None, - parent: None, - grouped: false, - rect: [0.0; 4], - color: None, - index: 0, - state: None, - quads: Vec::new(), - locked: false, - intent: None, - fill_sign, - no_rotate: false, - points: Vec::new(), - } - } - - #[test] - fn hover_popup_skips_fill_and_sign_marks() { - assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello")); - assert_eq!(annotation_hover_text(&annotation(true)), None); - } - fn signature(signed: bool, visible: bool, page: Option, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo { pdfcraft_engine::SignatureInfo { field: "Signature1".into(), diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index bf9b5fe72c..927d1b511c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{QuickTool, icons}; @@ -842,7 +843,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && pressed_here @@ -901,7 +902,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. @@ -961,7 +962,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && pressed_here @@ -995,7 +996,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.clicked() && over_page @@ -1011,7 +1012,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); } if resp.clicked() && over_page @@ -1030,7 +1031,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its // text wraps inside it (#2669). @@ -1146,6 +1147,46 @@ fn clamp_to(r: Rect, p: Pos2) -> Pos2 { pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom())) } +/// Whether `p` is on one of this page's links. +fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { + cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) +} + +/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat +/// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays +/// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a +/// comment is moved, resized or drawn. +pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { + let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser)); + if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() { + return; + } + let Some(p) = cx.ptr.pos else { return }; + // Inspection sorts comments by position, but the renderer paints them in `/Annots` order: the + // tip is that of the one drawn on top, among those with something to say (#3620). + let top = cx + .comments() + .filter(|a| cx.screen_rects(a).any(|r| r.contains(p))) + .filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text))) + .max_by_key(|(index, _)| *index); + if let Some((_, text)) = top { + view.cursor.describe(Subject::Comment, text); + } +} + +/// What hovering `a` says, as Acrobat's popups do. Replies and Fill & Sign's marks (typed text, +/// checks, signatures) say nothing: they are page content being edited, not comments. Neither +/// does a highlight with no text of its own, when the preferences say so. +fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option { + let empty_highlight = a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); + if a.is_reply() || a.fill_sign || prefs.hide_empty_highlight_hover && empty_highlight { + return None; + } + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + let body = a.contents.clone().unwrap_or_default(); + Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) +} + /// The Select tool: hover, select, move, resize, edit, delete. fn select_input( ui: &egui::Ui, @@ -1170,17 +1211,26 @@ fn select_input( let mut consumed = false; if let Some(p) = pointer { if point_at(p).is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Crosshair); } else if let Some(h) = handle_at(p) { - ui.ctx().set_cursor_icon(match h { - (0, _) => egui::CursorIcon::ResizeVertical, - (_, 0) => egui::CursorIcon::ResizeHorizontal, - (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe, - _ => egui::CursorIcon::ResizeNeSw, - }); - } else if let Some(a) = cx.hit(p) { + view.cursor.claim( + Layer::Handle, + match h { + (0, _) => egui::CursorIcon::ResizeVertical, + (_, 0) => egui::CursorIcon::ResizeHorizontal, + (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe, + _ => egui::CursorIcon::ResizeNeSw, + }, + ); + } else if let Some(a) = cx.hit(p) + // A click on a highlight over a link follows the link, so the link's hand and tip + // show there. + && !(a.subtype == "Highlight" && on_link(cx, p)) + { let movable = cx.allowed && !is_markup(&a.subtype); - ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }); + let icon = if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; + // Signatures and Fill & Sign's marks are placed on the page rather than comments on it. + view.cursor.claim(if a.fill_sign { Layer::Placed } else { Layer::Comment }, icon); } } if resp.drag_started() @@ -1254,12 +1304,8 @@ fn select_input( // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { - let follow_link = cx.tool == QuickTool::Select - && !consumed - && cv.gesture.is_none() - && !resp.double_clicked() - && pointer - .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))); + let follow_link = + cx.tool == QuickTool::Select && !consumed && cv.gesture.is_none() && !resp.double_clicked() && pointer.is_some_and(|p| on_link(cx, p)); match pointer.and_then(|p| cx.hit(p)) { Some(a) if a.subtype == "Highlight" && follow_link => { // A plain click follows the link beneath text highlighting. Keep other @@ -2383,6 +2429,44 @@ mod tests { PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] } } + fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation { + Annotation { + page: 0, + subtype: subtype.into(), + author: None, + contents: contents.map(str::to_string), + modified: None, + name: None, + in_reply_to: None, + parent: None, + grouped: false, + rect: [0.0; 4], + color: None, + index: 0, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign, + no_rotate: false, + points: Vec::new(), + } + } + + #[test] + fn the_hover_tip_skips_fill_and_sign_marks_replies_and_bare_highlights_when_asked() { + let prefs = CommentPrefs::default(); + assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), false), &prefs).as_deref(), Some("FreeText\nHello")); + assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), true), &prefs), None); + let reply = Annotation { in_reply_to: Some("1 0 R".into()), parent: Some((0, 1)), ..annotation("Text", Some("Agreed"), false) }; + assert_eq!(hover_tip(&reply, &prefs), None); + let bare = Annotation { author: Some("Ana".into()), ..annotation("Highlight", None, false) }; + assert_eq!(hover_tip(&bare, &prefs).as_deref(), Some("Ana")); + let hide = CommentPrefs { hide_empty_highlight_hover: true, ..CommentPrefs::default() }; + assert_eq!(hover_tip(&bare, &hide), None); + assert_eq!(hover_tip(&annotation("Highlight", Some("Check this"), false), &hide).as_deref(), Some("Highlight\nCheck this")); + } + fn near(a: Option<([f32; 2], f32)>, b: ([f32; 2], f32)) -> bool { a.is_some_and(|([x, y], s)| (x - b.0[0]).abs() < 1e-3 && (y - b.0[1]).abs() < 1e-3 && (s - b.1).abs() < 1e-3) } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 0da3b235c8..56f5ac2729 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::widgets; @@ -347,7 +348,6 @@ pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: /// used the input. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( - ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, @@ -398,13 +398,8 @@ pub(crate) fn page_input( .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); - // Cursors only where the page shows: the mode bar and side panels over a page scrolled under - // them keep their own (#2755). - let shows = resp.contains_pointer(); if adding_text && hit.is_none() { - if shows { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); if resp.clicked() { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { @@ -429,10 +424,10 @@ pub(crate) fn page_input( } return consumed; } - if let Some((_, left, top)) = corner.filter(|_| shows) { - ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } else if hit.is_some() && shows { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + if let Some((_, left, top)) = corner { + view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + } else if hit.is_some() { + view.cursor.claim(Layer::Placed, egui::CursorIcon::Move); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 1f1e1aa131..9e0d69b26e 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; /// A crop rectangle being dragged: (page, start on screen). @@ -31,7 +32,7 @@ pub(crate) fn page_input( ) -> bool { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); + view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); } if !allowed { return false; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 4f5630c0b9..1f0e646094 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -617,7 +618,9 @@ fn crop_input( painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text); - if let Some(p) = hover_pos { + // Cropping takes over its page's cursor, and the buttons' wherever they are; other pages + // keep theirs. + if let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p) || done.contains(*p) || cancel.contains(*p)) { let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { match g { 0 | 3 => egui::CursorIcon::ResizeNwSe, @@ -632,7 +635,7 @@ fn crop_input( } else { egui::CursorIcon::Default }; - ui.ctx().set_cursor_icon(icon); + view.cursor.claim(Layer::Editor, icon); } let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)); let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); @@ -911,7 +914,7 @@ pub(crate) fn image_input( handles(b); } if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { - ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + view.cursor.claim(Layer::Handle, if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } // The rotate handle: a knob above the top edge, joined to it by a short stem. let (knob, stem) = rotate_knob(b, ui.clip_rect()); @@ -919,7 +922,7 @@ pub(crate) fn image_input( painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } if allow_interaction @@ -959,7 +962,7 @@ pub(crate) fn image_input( let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; @@ -1032,11 +1035,14 @@ pub(crate) fn image_input( Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); } } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); + view.cursor.claim( + Layer::Drag, + match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }, + ); return true; } if allow_interaction @@ -1074,12 +1080,13 @@ pub(crate) fn image_input( } let Some(b) = boxes.get(hit).copied() else { return false }; let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0); - ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) { - (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe, - (true, Some(_)) => egui::CursorIcon::ResizeNeSw, - (true, None) => egui::CursorIcon::Move, - _ => egui::CursorIcon::PointingHand, - }); + match (selected == Some(hit), corner) { + (true, Some(0 | 3)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNwSe), + (true, Some(_)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNeSw), + (true, None) => view.cursor.claim(Layer::Placed, egui::CursorIcon::Move), + // A click selects it. + _ => view.cursor.claim(Layer::Placed, egui::CursorIcon::PointingHand), + } if resp.clicked() || resp.secondary_clicked() { view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None }); } @@ -1288,7 +1295,7 @@ pub(crate) fn page_input( if resizing { draw_handles(preview); } - ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); + view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); if resp.drag_stopped() || !ptr.any_down { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); @@ -1337,17 +1344,13 @@ pub(crate) fn page_input( draw_handles(boxes[hit]); } // A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it - // for typing); an edge rewraps it. The open one takes the text cursor. Only where the page - // shows, so the mode bar and side panels over a paragraph scrolled under them keep their own - // cursor (#2755). - if resp.contains_pointer() { - ui.ctx().set_cursor_icon(if on_edge { - egui::CursorIcon::ResizeHorizontal - } else if active.is_none() { - egui::CursorIcon::Move - } else { - egui::CursorIcon::Text - }); + // for typing); an edge rewraps it. The open one takes the text cursor. + if on_edge { + view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeHorizontal); + } else if active.is_none() { + view.cursor.claim(Layer::Text, egui::CursorIcon::Move); + } else { + view.cursor.claim(if active == Some(hit) { Layer::Editor } else { Layer::Text }, egui::CursorIcon::Text); } if resp.clicked() { view.image_selection = None; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index aab071b508..0861cd3cdd 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; @@ -651,10 +652,10 @@ pub(crate) fn page_input( if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) { let tex = image_texture(ui, image, preview); xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE); - ui.ctx().set_cursor_icon(egui::CursorIcon::None); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::None); } } else { - ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); + view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } if !resp.clicked() { return None; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 28494160c5..e0d51a3da2 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -14,6 +14,7 @@ use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, fi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; @@ -151,20 +152,7 @@ fn toggle_edit(f: &FormField, w: &FormWidget) -> Option { Some(Edit::SetFieldValue { name: f.name.clone(), value }) } -/// Where the pointer is relative to one page's form widgets. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum FieldPointer { - /// Not over a field. - Away, - /// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see - /// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set - /// on printed text, is a button, not text to select. - Over, - /// The field owns the press or click. - Owned, -} - -/// Clicks on form widgets of one page. +/// Clicks on form widgets of one page. `true` when a field owns the press or click. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, @@ -176,8 +164,8 @@ pub(crate) fn page_input( form: &[FormField], allowed: bool, view: &mut DocView, -) -> FieldPointer { - let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away }; +) -> bool { + let Some(p) = ptr.within(xf.rect) else { return false }; let hit = form.iter().find_map(|f| { f.widgets .iter() @@ -185,7 +173,7 @@ pub(crate) fn page_input( .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) .map(|(i, w)| (f, i, w)) }); - let Some((f, wi, w)) = hit else { return FieldPointer::Away }; + let Some((f, wi, w)) = hit else { return false }; // The field's tooltip (`/TU`), as Acrobat shows it on hover (#3854). ISO 32000-2 Table 226 // makes it the name the user interface identifies the field by. if let Some(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) { @@ -196,15 +184,16 @@ pub(crate) fn page_input( let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); // A radio button of a group can be read-only on its own. let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature); - // Only where the page shows: the mode bar and side panels over a field scrolled under them - // keep their own cursor (#2755). - if resp.contains_pointer() { - ui.ctx().set_cursor_icon(match (usable, f.kind) { + // Over the page's text too: a check box drawn with a glyph, or set on printed text, is a + // button, not text to select. + view.cursor.claim( + Layer::Field, + match (usable, f.kind) { (false, _) => egui::CursorIcon::NotAllowed, (true, FormFieldKind::Text) => egui::CursorIcon::Text, _ => egui::CursorIcon::PointingHand, - }); - } + }, + ); // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); @@ -214,11 +203,11 @@ pub(crate) fn page_input( view.forms.pressed = true; } if !resp.clicked() { - return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over }; + return resp.is_pointer_button_down_on(); } if !allowed { view.forms.notice = Some(FormNotice::Security); - return FieldPointer::Owned; + return true; } match f.kind { _ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())), @@ -289,7 +278,7 @@ pub(crate) fn page_input( } } } - FieldPointer::Owned + true } /// The edit the focused field's draft makes, if it differs from the field's value. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..f909698238 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -104,6 +104,7 @@ pub mod icons; pub mod last_session; pub mod last_view; pub mod motion; +mod page_cursor; mod page_pointer; mod page_selection_ui; mod pageboxes; diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index f7768912dd..012a95f95f 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -86,7 +87,10 @@ pub(crate) fn page_input( } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - ui.ctx().set_cursor_icon(if hit.is_some() { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Crosshair }); + match hit { + Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), + None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), + } if resp.secondary_clicked() && let Some(l) = hit { diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 79670ad304..17e3ced693 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -1,5 +1,5 @@ //! Measure objects: page-space clicks, live readings and persistent drawing scales. -use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, widgets}; +use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets}; use egui::{Color32, Pos2, Stroke}; use pdfcraft_engine::{ Document, Edit, Style, @@ -200,7 +200,7 @@ pub(crate) fn page_input( let mut hover = None; let mut snap = None; if let Some(pos) = pos { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); let (vx, vy) = xf.screen_to_view(pos); let [x, y] = info.view_to_user(vx, vy); let mut point = [f64::from(x), f64::from(y)]; diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index d495116c6c..8d4a1137e6 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -255,7 +256,7 @@ fn input( } else { s.gesture = Some(gesture); } - ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); + view.cursor.claim(Layer::Drag, if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { @@ -265,7 +266,7 @@ fn input( if s.page == Some(page) && !s.selected.is_empty() { let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); if union.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + view.cursor.claim(Layer::Placed, egui::CursorIcon::Move); } return true; } diff --git a/crates/ui-egui/src/page_cursor.rs b/crates/ui-egui/src/page_cursor.rs new file mode 100644 index 0000000000..9abfb7e994 --- /dev/null +++ b/crates/ui-egui/src/page_cursor.rs @@ -0,0 +1,332 @@ +//! The cursor the pages show, and the tip beside it, set once a frame. +//! +//! A page module claims the cursor for what it has under the pointer, on a [`Layer`], and never +//! sets egui's cursor itself: where each module set it, the last write of a frame won, so the +//! order of the per-page chain decided the cursor and not what a press there does. A link's hand +//! showed over every tool though only the Select tool follows links, and the I-beam of the text +//! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the +//! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is +//! that claim's own (a link's target, so only where a press follows the link), or else what lies +//! under the pointer says (a comment, or a signature's validation badge), with any tool, as +//! Acrobat shows a comment's tooltip. A test in `page_pointer.rs` keeps page modules from setting +//! the cursor. + +use egui::CursorIcon; + +use crate::QuickTool; + +/// What a claim is for, from the bottom up: a claim on a higher layer wins. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Layer { + /// The page under the active tool: the crosshair it places or draws with, its I-beam or hand. + ToolArea, + /// The page's text, to select, and Edit text's paragraphs. + Text, + /// Links. + Link, + /// Comments: notes, shapes and markup. + Comment, + /// Form fields. + Field, + /// What a drag moves whole: signatures and Fill & Sign's marks, added text and images, + /// selected objects, pictures in Edit text. + Placed, + /// The grips of the selected item: resize, rewrap and crop handles, paragraph edges. + Handle, + /// An open editor: a paragraph being typed in, a picture being cropped. + Editor, + /// A drag in progress: what is dragged keeps its cursor until the release. + Drag, +} + +/// Every layer, bottom first. +const LAYERS: [Layer; 9] = + [Layer::ToolArea, Layer::Text, Layer::Link, Layer::Comment, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag]; + +impl Layer { + /// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims + /// count. Only the Select tool follows links (the Link tool edits them) and picks up + /// comments: every other tool draws, places or selects text over them. The other layers are + /// claimed only by the modules of the tools that act on them, so they count for every tool. + pub(crate) fn reached_by(self, tool: QuickTool) -> bool { + match self { + Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link), + Layer::Comment => tool == QuickTool::Select, + Layer::ToolArea | Layer::Text | Layer::Field | Layer::Placed | Layer::Handle | Layer::Editor | Layer::Drag => true, + } + } +} + +/// A claim on the cursor: the icon, and the tip that goes with it (a link's target). +#[derive(Clone, Debug, PartialEq)] +struct Claim { + icon: CursorIcon, + tip: Option, +} + +/// What a description tells about, from the least up: a higher one shows over a lower one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Subject { + /// A signature's validation badge: viewer chrome over the signer's own artwork. + SignatureBadge, + /// A comment: who wrote it, and what it says. + Comment, +} + +/// What the page modules claim for the cursor this frame. `canvas::document_area` sets the +/// cursor from it once, after the pages ([`PageCursor::resolve`]). +#[derive(Debug, Default)] +pub(crate) struct PageCursor { + /// The last claim on each layer, bottom layer first. + claims: [Option; LAYERS.len()], + /// What lies under the pointer says ([`PageCursor::describe`]). + description: Option<(Subject, String)>, +} + +impl PageCursor { + /// Shows `icon` while the pointer is over what lies on `layer`, unless the active tool + /// reaches a higher layer that is claimed too. + pub(crate) fn claim(&mut self, layer: Layer, icon: CursorIcon) { + self.claim_with_tip(layer, icon, None); + } + + /// As [`Self::claim`], with `tip` beside the pointer where the claim wins. + pub(crate) fn claim_with_tip(&mut self, layer: Layer, icon: CursorIcon, tip: Option) { + if let Some(slot) = self.claims.get_mut(layer as usize) { + *slot = Some(Claim { icon, tip }); + } + } + + /// Says what lies under the pointer without claiming the cursor: the tip shows with any tool, + /// beside that tool's cursor, as Acrobat shows a comment's tooltip, unless the winning claim + /// has a tip of its own. The description of the highest subject stands, and the last of + /// equals (the topmost of overlapping badges). + pub(crate) fn describe(&mut self, subject: Subject, text: String) { + if self.description.as_ref().is_none_or(|(held, _)| *held <= subject) { + self.description = Some((subject, text)); + } + } + + /// The claim on the highest layer `tool` reaches. + fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { + LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) + } + + /// Sets the cursor, once, to the icon of the claim on the highest layer `tool` reaches, and + /// returns the tip: that claim's own, or else the description. With no such claim the cursor + /// is left as it is. The claims are spent: the next frame starts with none. + pub(crate) fn resolve(&mut self, ctx: &egui::Context, tool: QuickTool) -> Option { + let PageCursor { claims, description } = std::mem::take(self); + let winner = Self::winner(claims, tool); + if let Some(w) = &winner { + ctx.set_cursor_icon(w.icon); + } + winner.and_then(|w| w.tip).or(description.map(|(_, text)| text)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::comments::CommentTool; + use crate::fill_sign::FillTool; + use crate::prepare::FieldTool; + + /// One of each tool. The match stops compiling when a tool is added, so that it gets a row + /// in [`Layer::reached_by`]'s tests. + fn tools() -> Vec { + let tools = vec![ + QuickTool::Select, + QuickTool::Hand, + QuickTool::Comment(CommentTool::Rectangle), + QuickTool::Comment(CommentTool::Highlight), + QuickTool::Measure(crate::measure_ui::Tool::Distance), + QuickTool::Crop, + QuickTool::EnhanceCamera, + QuickTool::Fill(FillTool::Signature), + QuickTool::Field(FieldTool::Text), + QuickTool::Redact, + QuickTool::AddText, + QuickTool::EditText, + QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved), + QuickTool::CustomStamp(0), + QuickTool::Link, + QuickTool::SignArea { certify: false }, + QuickTool::MarqueeZoom, + QuickTool::Snapshot, + QuickTool::ColumnSelect, + ]; + for tool in &tools { + match tool { + QuickTool::Select + | QuickTool::Hand + | QuickTool::Comment(_) + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::Redact + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot + | QuickTool::ColumnSelect => {} + } + } + tools + } + + #[test] + fn layers_are_listed_bottom_first_in_their_order() { + for (i, layer) in LAYERS.into_iter().enumerate() { + assert_eq!(layer as usize, i, "{layer:?}"); + } + assert!(LAYERS.windows(2).all(|w| w[0] < w[1])); + } + + #[test] + fn links_count_for_the_select_and_link_tools_only() { + for tool in tools() { + assert_eq!(Layer::Link.reached_by(tool), matches!(tool, QuickTool::Select | QuickTool::Link), "{tool:?}"); + } + } + + #[test] + fn comments_count_for_the_select_tool_only() { + for tool in tools() { + assert_eq!(Layer::Comment.reached_by(tool), tool == QuickTool::Select, "{tool:?}"); + } + } + + #[test] + fn the_other_layers_count_for_every_tool() { + for tool in tools() { + for layer in [Layer::ToolArea, Layer::Text, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag] { + assert!(layer.reached_by(tool), "{layer:?} for {tool:?}"); + } + } + } + + fn claims(claims: &[(Layer, CursorIcon, Option<&str>)]) -> PageCursor { + let mut cursor = PageCursor::default(); + for (layer, icon, tip) in claims { + cursor.claim_with_tip(*layer, *icon, tip.map(str::to_string)); + } + cursor + } + + fn winner(cursor: PageCursor, tool: QuickTool) -> Option { + PageCursor::winner(cursor.claims, tool) + } + + #[test] + fn the_highest_layer_the_tool_reaches_wins_with_its_tip() { + let on_text_under_a_link = [(Layer::Text, CursorIcon::Text, None), (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"))]; + let select = winner(claims(&on_text_under_a_link), QuickTool::Select); + assert_eq!(select, Some(Claim { icon: CursorIcon::PointingHand, tip: Some("https://example.org/".into()) })); + // A markup tool selects the text there, and the link's tip goes with its hand. + let highlight = winner(claims(&on_text_under_a_link), QuickTool::Comment(CommentTool::Highlight)); + assert_eq!(highlight, Some(Claim { icon: CursorIcon::Text, tip: None })); + // A tool reaching nothing claimed leaves the cursor alone. + assert_eq!(winner(claims(&[(Layer::Link, CursorIcon::PointingHand, None)]), QuickTool::Hand), None); + } + + #[test] + fn a_higher_claim_without_a_tip_hides_a_lower_ones_tip() { + let field_over_a_link = [(Layer::Link, CursorIcon::PointingHand, Some("Go to page 2")), (Layer::Field, CursorIcon::Text, None)]; + assert_eq!(winner(claims(&field_over_a_link), QuickTool::Select), Some(Claim { icon: CursorIcon::Text, tip: None })); + } + + #[test] + fn the_last_claim_on_a_layer_wins() { + let two_links = [(Layer::Link, CursorIcon::PointingHand, Some("first")), (Layer::Link, CursorIcon::PointingHand, Some("second"))]; + assert_eq!(winner(claims(&two_links), QuickTool::Select).and_then(|c| c.tip).as_deref(), Some("second")); + } + + /// Runs one frame that sets the cursor to `before`, if any, then resolves `claims` for + /// `tool`. Returns the frame's cursor and the tip. + fn frame(ctx: &egui::Context, before: Option, claims: &mut PageCursor, tool: QuickTool) -> (CursorIcon, Option) { + let mut tip = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + if let Some(icon) = before { + ui.ctx().set_cursor_icon(icon); + } + tip = claims.resolve(ui.ctx(), tool); + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + (output.platform_output.cursor_icon, tip) + } + + #[test] + fn resolve_sets_the_winners_cursor_and_spends_the_claims() { + let ctx = egui::Context::default(); + let on_a_link = || claims(&[(Layer::ToolArea, CursorIcon::Crosshair, None), (Layer::Link, CursorIcon::PointingHand, Some("tip"))]); + assert_eq!(frame(&ctx, None, &mut on_a_link(), QuickTool::Select), (CursorIcon::PointingHand, Some("tip".into()))); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(frame(&ctx, None, &mut on_a_link(), stamp), (CursorIcon::Crosshair, None), "the stamp's crosshair, and no link tip"); + // Nothing the tool reaches: the cursor stays as the frame set it. + let mut only_a_link = claims(&[(Layer::Link, CursorIcon::PointingHand, Some("tip"))]); + only_a_link.describe(Subject::Comment, "Ana".into()); + assert_eq!(frame(&ctx, Some(CursorIcon::Help), &mut only_a_link, QuickTool::Hand), (CursorIcon::Help, Some("Ana".into()))); + // Resolving spends the claims, reached or not, and the description. + assert_eq!(only_a_link.description, None); + assert_eq!(winner(only_a_link, QuickTool::Select), None); + } + + #[test] + fn a_description_shows_with_any_tool_unless_the_winner_has_a_tip_of_its_own() { + let ctx = egui::Context::default(); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + let link = (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/")); + let described = |claimed: &[(Layer, CursorIcon, Option<&str>)]| { + let mut cursor = claims(claimed); + cursor.describe(Subject::Comment, "Ana\nCheck this".into()); + cursor + }; + let comment = Some("Ana\nCheck this".to_string()); + // A comment under the stamp's crosshair: the tool's cursor, and what the comment says. + let crosshair = (Layer::ToolArea, CursorIcon::Crosshair, None); + assert_eq!(frame(&ctx, None, &mut described(&[crosshair]), stamp), (CursorIcon::Crosshair, comment.clone())); + // A link the tool follows speaks for itself; one it doesn't keeps its tip to itself. + assert_eq!( + frame(&ctx, None, &mut described(&[crosshair, link]), QuickTool::Select), + (CursorIcon::PointingHand, Some("https://example.org/".into())) + ); + assert_eq!(frame(&ctx, None, &mut described(&[crosshair, link]), stamp), (CursorIcon::Crosshair, comment.clone())); + // A winner without a tip of its own, such as the Select tool's comment claim. + let picked_up = (Layer::Comment, CursorIcon::Move, None); + assert_eq!(frame(&ctx, None, &mut described(&[link, picked_up]), QuickTool::Select), (CursorIcon::Move, comment)); + } + + #[test] + fn a_comment_says_more_than_a_signature_badge_and_the_last_badge_stands() { + let badge_then_comment = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor.describe(Subject::Comment, "Ana".into()); + cursor + }; + let comment_then_badges = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::Comment, "Ana".into()); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor + }; + let two_badges = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor.describe(Subject::SignatureBadge, "Signature is invalid".into()); + cursor + }; + let said = |cursor: PageCursor| cursor.description.map(|(_, text)| text); + assert_eq!(said(badge_then_comment).as_deref(), Some("Ana")); + assert_eq!(said(comment_then_badges).as_deref(), Some("Ana"), "whichever came first"); + assert_eq!(said(two_badges).as_deref(), Some("Signature is invalid"), "the badge painted last is on top"); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 2635693676..5d7cf72e1e 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::comments; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; @@ -344,7 +345,7 @@ pub(crate) fn page_input( return out; } if let Some(tool) = tool { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.drag_started() || resp.clicked() { let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); @@ -397,9 +398,9 @@ pub(crate) fn page_input( f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) }); if let Some((_, left, top)) = corner { - ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } else if hit.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + view.cursor.claim(Layer::Field, egui::CursorIcon::Move); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 6a5bee7ea4..52cadc2aa2 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS, use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::{PdfCraftApp, widgets}; @@ -237,7 +238,6 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// the gesture is a box. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( - ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, @@ -263,7 +263,7 @@ pub(crate) fn page_input( } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; if !over_text(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 8f6b2c4a0c..4df02cbf41 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -163,7 +164,7 @@ pub(crate) fn page_input( ) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index e0315763fa..8e530ac58e 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::{PdfCraftApp, QuickTool}; @@ -16,7 +17,7 @@ pub type Marquee = (usize, Rect, [f32; 4]); pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..1b5181ff60 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3974,12 +3974,14 @@ fn dragging_the_rotate_knob_turns_a_page_image() { let to = egui::pos2(c.x + 100.0, c.y); h.hover_at(knob); h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "the knob offers to turn the picture"); h.drag_at(knob); h.run_steps(1); for k in 1..=4 { h.hover_at(knob + (to - knob) * (k as f32 / 4.0)); h.run_steps(1); } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "and holds it while it turns"); h.drop_at(to); h.run_steps(4); let doc = h.state().session.get(id).unwrap(); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index f2b6c0e907..74120834bc 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -3,12 +3,14 @@ //! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's //! cursor, tips or presses through. //! -//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor -//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4). +//! The ignored tests pin live bugs that later steps of the page pointer router fix: one hit test +//! (step 3), and press-time capture with cancel (step 4). use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; +use pdfcraft_ui_egui::fill_sign::FillTool; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -76,6 +78,18 @@ fn comment_on_text_pdf() -> Vec { ]) } +/// A 300×400 page with two rectangle comments overlapping at x 100..150, y 200..250: "Bottom +/// note" first in `/Annots`, then "Top note" over it. +fn stacked_comments_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [50 150 150 250] /C [1 0 0] /F 4 /T (Ana) /Contents (Bottom note) >>", + "<< /Type /Annot /Subtype /Square /Rect [100 200 200 300] /C [0 0 1] /F 4 /T (Ben) /Contents (Top note) >>", + ]) +} + /// A 300×400 page with one line of text at x 40, y 300. fn text_pdf() -> Vec { let text = stream(LINE); @@ -173,6 +187,12 @@ fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone() } +/// Whether the page's hover tip shows and says `text`. +fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool { + let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).filter(|_| tip_shown(h)) else { return false }; + h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center())) +} + /// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from /// worker threads), and returns the last cursor seen. fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { @@ -191,7 +211,6 @@ fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl F // The cursor promises one thing and a press does another. #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn stamp_tool_over_a_link_shows_its_crosshair() { let mut h = open(link_pdf(), "150", false); stamp_tool(&mut h); @@ -205,7 +224,6 @@ fn stamp_tool_over_a_link_shows_its_crosshair() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn rectangle_tool_over_a_link_shows_its_crosshair() { let mut h = open(link_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); @@ -218,7 +236,6 @@ fn rectangle_tool_over_a_link_shows_its_crosshair() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn edit_text_over_a_linked_word_offers_the_move_arrow() { let mut h = open(linked_text_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::EditText; @@ -242,7 +259,6 @@ fn edit_text_over_a_linked_word_offers_the_move_arrow() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { let mut h = open(comment_on_text_pdf(), "150", false); // Inside the rectangle where there is no text, and inside it over a word. @@ -259,6 +275,71 @@ fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too"); } +#[test] +fn a_links_tip_shows_only_with_its_hand() { + let mut h = open(link_pdf(), "150", false); + let on_link = at(&h, 150.0, 325.0); + let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, nudge: f32| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + // A new position each time, so the hover is fresh. + let cursor = cursor_at(h, on_link + egui::vec2(nudge, 0.0)); + h.run_steps(2); + (cursor, tip_says(h, "https://example.org/")) + }; + use egui::CursorIcon::{Crosshair, PointingHand}; + assert_eq!(hover(&mut h, QuickTool::Select, 0.0), (PointingHand, true), "the Select tool follows the link, and the tip says where to"); + assert_eq!(hover(&mut h, QuickTool::Link, 1.0), (PointingHand, true), "the Link tool edits it"); + let stamp = QuickTool::Stamp(StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, 2.0), (Crosshair, false), "a click places a stamp, so neither hand nor tip offers the link"); + let rectangle = QuickTool::Comment(CommentTool::Rectangle); + assert_eq!(hover(&mut h, rectangle, 3.0), (Crosshair, false), "a drag draws a rectangle"); +} + +/// Over two comments the tip names the one drawn on top (#3620), and a click should select that +/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in +/// inspection's order, which is by top edge, and selects the one underneath. +#[test] +#[ignore = "step 3 of the page pointer router: one hit test"] +fn a_click_on_stacked_comments_selects_the_one_drawn_on_top() { + let mut h = open(stacked_comments_pdf(), "150", false); + let overlap = at(&h, 125.0, 225.0); + assert_eq!(cursor_at(&mut h, overlap), egui::CursorIcon::Move); + h.run_steps(2); + assert!(tip_says(&h, "Top note") && !tip_says(&h, "Bottom note"), "the tip names the comment drawn on top"); + mouse_click(&mut h, overlap); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "a click selects it too"); +} + +#[test] +fn a_comments_tip_shows_with_every_tool_beside_its_cursor() { + let mut h = open(comment_on_text_pdf(), "150", false); + // Inside the rectangle comment by "Reviewer": where there is no text, and over a word. + let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0)); + let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + let cursor = wait_for_cursor(h, p, |c| c == want); + h.run_steps(2); + (cursor, tip_says(h, "Reviewer")) + }; + use egui::CursorIcon::{Crosshair, Move, Text}; + // A new position each time, so the hover is fresh. + let nudge = |n: f32| blank + egui::vec2(n, 0.0); + assert_eq!(hover(&mut h, QuickTool::Select, blank, Move), (Move, true), "the Select tool picks the comment up"); + let stamp = QuickTool::Stamp(StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, nudge(1.0), Crosshair), (Crosshair, true), "a stamp keeps its crosshair, and the comment still says whose it is"); + let rectangle = QuickTool::Comment(CommentTool::Rectangle); + assert_eq!(hover(&mut h, rectangle, nudge(2.0), Crosshair), (Crosshair, true), "so does a drawing tool"); + let highlight = QuickTool::Comment(CommentTool::Highlight); + assert_eq!(hover(&mut h, highlight, on_text, Text), (Text, true), "and the highlighter its I-beam"); + // Not while drawing freehand, where it would cover the next stroke (#429), nor with Fill & Sign. + let ink = QuickTool::Comment(CommentTool::Ink); + assert_eq!(hover(&mut h, ink, nudge(3.0), Crosshair), (Crosshair, false), "freehand"); + let fill = QuickTool::Fill(FillTool::Check); + assert_eq!(hover(&mut h, fill, nudge(4.0), Crosshair), (Crosshair, false), "Fill & Sign"); +} + /// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points /// left of the edge. Returns the cursor shown at the press point and the line count after. fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) { diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..b37f134a1a 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -331,13 +331,25 @@ fn clicking_an_empty_signature_field_signs_it() { let dir = dir().join("field"); std::fs::create_dir_all(&dir).unwrap(); let mut h = harness(dir.clone()); + sign_the_empty_field(&mut h); + let s = h.state(); + let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); + assert!(sig.signed && sig.visible); +} + +/// The centre of `WITH_FIELD`'s signature field, opened as the second document. +fn field_centre(h: &Harness<'static, PdfCraftApp>) -> Pos2 { + let r = h.state().views[1].page_screen_rect(0).expect("on screen"); + pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height()) +} + +/// Opens `WITH_FIELD` as the second document and signs its empty field with the test ID, by +/// clicking the field. +fn sign_the_empty_field(h: &mut Harness<'static, PdfCraftApp>) { h.state_mut().open_bytes("form.pdf", None, WITH_FIELD.to_vec()).unwrap(); h.state_mut().active = Some(1); h.run_steps(6); - let p = { - let r = h.state().views[1].page_screen_rect(0).expect("on screen"); - pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height()) - }; + let p = field_centre(h); h.hover_at(p); h.run_steps(1); h.drag_at(p); @@ -363,9 +375,39 @@ fn clicking_an_empty_signature_field_signs_it() { h.run_steps(2); h.get_by_label("Sign").click(); h.run_steps(6); - let s = h.state(); - let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); - assert!(sig.signed && sig.visible); +} + +/// Whether the page's hover tip shows and says `text`. +fn tip_says(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover")); + let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover")).filter(|_| m.areas().is_visible(&layer))) else { return false }; + h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center())) +} + +/// A signed field's validation badge is the viewer's own mark, not part of the page: its verdict +/// is the tip with any tool, beside that tool's cursor. +#[test] +fn a_signature_badge_says_its_verdict_with_any_tool() { + let dir = dir().join("badge"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + sign_the_empty_field(&mut h); + let verdict = { + let s = h.state(); + let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); + sig.summary() + }; + let p = field_centre(&h); + let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, at: Pos2| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + h.hover_at(at); + h.run_steps(4); + (h.output().platform_output.cursor_icon, tip_says(h, verdict)) + }; + assert!(hover(&mut h, QuickTool::Select, p).1, "the Select tool shows the verdict"); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..45a107ab94 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1202,8 +1202,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] -notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." +evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/pointer.rs::a_comments_tip_shows_with_every_tool_beside_its_cursor", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] +notes = "A popup with the author and comment text, as Acrobat shows it: with every tool, beside that tool's own cursor (Acrobat does the same with Select, Hand, Add comment, Highlight, Rectangle and Stamp); not with Fill & Sign, while drawing freehand (#429) or while a comment is moved, resized or drawn. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Still missing: Acrobat's hover outline on a highlight with every tool, and its hit on an unfilled shape's border only. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] id = "view.page-size-hover" From 61f92a1d0c9364283a90349ebfa615e55dcb880a Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 19 Oct 2026 19:34:27 +0200 Subject: [PATCH 14/35] test(ui): page modules claim the cursor and never set it A sibling of page_modules_read_the_pointer_only_through_page_pointer in page_pointer.rs, sharing its scan of the page modules (now `misuses`, which both tests call): it fails on set_cursor_icon, a `.cursor_icon = ` write to egui's output, on_hover_cursor and on_hover_and_drag_cursor in a page module, naming the file and line and pointing to the claims in src/page_cursor.rs. Only the resolver, which is not a page module, sets the pages' cursor. NOT_PAGE_CURSOR allows the three writers that are not the pages' cursor: the signature dialog's Scan tab (fill_sign::scan_tab), the paragraph editor's frame (edit_text_ui::overlay, an Area of its own drawn after the pages' cursor is set) and the Calculation order panel, whose rows are dragged to reorder them (calc_order_ui::panel). An entry that sets no cursor any more fails too. Seen failing with a set_cursor_icon put back into canvas.rs's link loop, an output_mut cursor write in forms_ui, an on_hover_cursor in comments and the Scan tab's write removed: one message each. The pointer guard still fails on a hover_pos() read put into crop.rs. Rebased onto main's newer page modules, it named one write before the panel's entry was added, calc_order_ui.rs's Grabbing: main's other new cursors are claims now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/page_pointer.rs | 80 ++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index a6a318c1fd..c6baf70908 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -346,22 +346,33 @@ mod tests { rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) } - /// Whether `line` calls `read`, and not a longer name that ends in it. - fn reads(line: &str, read: &str) -> bool { - line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + /// Ways to set egui's cursor. + const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; + + /// Functions in the page modules that set a cursor other than the pages': (file, function, + /// why). + const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [ + ("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"), + ("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"), + ("calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"), + ]; + + /// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its + /// `.`, is never part of a longer name). + fn uses(line: &str, name: &str) -> bool { + line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } - /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is - /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back - /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. - #[test] - fn page_modules_read_the_pointer_only_through_page_pointer() { + /// Each use of one of `names` in the page modules' code (their tests left out) outside the + /// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that + /// uses none of them any more, as "src/file: fn function unused". + fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut problems = Vec::new(); - let mut needed = vec![false; NOT_PAGE_INPUT.len()]; + let mut needed = vec![false; allowed.len()]; for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // Tests drive egui's input themselves. + // Tests drive egui's input and read its output themselves. let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); @@ -376,24 +387,36 @@ mod tests { if line.trim_start().starts_with("//") { continue; } - for read in RAW_READS.into_iter().filter(|read| reads(line, read)) { - match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) { + for name in names.iter().filter(|name| uses(line, name)) { + match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) { Some(k) => needed[k] = true, - None => problems.push(format!( - "src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \ - (src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \ - lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.", - n + 1 - )), + None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)), } } } } - for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) { + for ((file, name, _), needed) in allowed.iter().zip(needed) { if !needed { - problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT.")); + problems.push(format!("src/{file}: fn {name} {unused}")); } } + problems + } + + /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is + /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back + /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. + #[test] + fn page_modules_read_the_pointer_only_through_page_pointer() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut problems = misuses( + &RAW_READS, + &NOT_PAGE_INPUT, + "reads egui's pointer state in a page module. Read `PagePointer` (src/page_pointer.rs) instead: it knows \ + what is drawn over the pages and where a touch lifted. Not page input? Add the function to NOT_PAGE_INPUT \ + there, with why.", + "reads no pointer state any more: remove it from NOT_PAGE_INPUT.", + ); // A module that takes the page pointer is a page module, so it is checked too. let mut dirs = vec![src.clone()]; while let Some(dir) = dirs.pop() { @@ -416,4 +439,21 @@ mod tests { } assert!(problems.is_empty(), "{}", problems.join("\n")); } + + /// Page modules claim the cursor and never set it. Where each set it, the last write of a + /// frame won, so the order of the per-page chain decided the cursor and not what a press + /// does: a link's hand over every tool, the I-beam over a comment that a drag moves. + #[test] + fn page_modules_claim_the_cursor_and_never_set_it() { + let problems = misuses( + &CURSOR_WRITES, + &NOT_PAGE_CURSOR, + "sets the cursor in a page module, where the last write of a frame would win. Claim it instead, on the \ + layer of what is under the pointer (`view.cursor.claim`, src/page_cursor.rs): `document_area` sets it \ + once, from the claim on the highest layer the active tool reaches. Not the pages' cursor? Add the \ + function to NOT_PAGE_CURSOR in src/page_pointer.rs, with why.", + "sets no cursor any more: remove it from NOT_PAGE_CURSOR.", + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); + } } From 5815dbfb794bec4dc5bb7dfa449cd36801171eff Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 19 Oct 2026 19:34:27 +0200 Subject: [PATCH 15/35] docs: page modules claim the cursor, never set it A "Page cursor" non-negotiable in CLAUDE.md, under "Page input": page modules claim the cursor for what is under the pointer (view.cursor.claim, crates/ui-egui/src/page_cursor.rs) and never call set_cursor_icon, write cursor_icon or use on_hover_cursor; document_area sets it once, from the claim on the highest layer the active tool reaches. It names the test that enforces it, page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56e..944479805b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. +- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. From ee6f2a017f85e57d1645bf895ce306dc2a7b8ce0 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:25:48 +0100 Subject: [PATCH 16/35] fix(ui): enforce copy permission before reading bookmark styles --- crates/ui-egui/src/bookmark_styles_ui.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index 718e582f70..dcdcfc80ef 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -36,6 +36,13 @@ impl PdfCraftApp { /// largest is offered for level 1 when there is more than one (headings are larger than the /// text around them; a single style has no headings to tell apart). pub fn open_text_style_bookmarks(&mut self, id: DocId) { + // The style list includes samples copied from the document's text. Enforce the PDF's + // copy permission before extracting any of that text, not only when creating bookmarks. + let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy())); + if !can_copy { + self.notify_tr("Copying text and images is not permitted"); + return; + } let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); let first = (styles.len() > 1).then_some(0); self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] }; From 67ecccfc23bc78b09b68b6e52c6b5416168ca2ee Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:40:18 +0100 Subject: [PATCH 17/35] fix(ui): reuse translated copy-permission message --- crates/ui-egui/src/bookmark_styles_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index dcdcfc80ef..890a5f2efd 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -40,7 +40,7 @@ impl PdfCraftApp { // copy permission before extracting any of that text, not only when creating bookmarks. let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy())); if !can_copy { - self.notify_tr("Copying text and images is not permitted"); + self.notify_tr("The document's security settings don't allow copying text and images"); return; } let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); From 0cd6a197e40e31b1c0dd0cb84b047f6df1e295be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:47:34 +0000 Subject: [PATCH 18/35] M6: a radio button without its /Parent link leaves its group's /Kids too Deleting one widget took it out of the field tree through its /Parent, which a damaged file can leave out or point at another field; the group kept the widget in its /Kids with no page showing it, and the delete reported success. The field that owns the widget drops it from its own /Kids as well (CodeRabbit on #4631). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/forms/src/author/lifecycle.rs | 9 +++++++++ crates/forms/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index 4fb045d7b8..d5d7d14cdd 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -219,6 +219,15 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() drop_from_calculation_order(doc, |r| r == w.obj)?; let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); let mut changed = false; + // The field's own `/Kids` is what made the widget its: `detach` follows the widget's + // `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep + // a widget no page shows. + if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && kids.iter().any(|k| k.as_ref() == Some(w.obj)) + { + d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect())); + changed = true; + } if per_widget_opt && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) && opt.len() == f.widgets.len() diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b85aeb726c..a0d57a1b5f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4693,6 +4693,29 @@ fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { assert_eq!(annots(&doc), [6, 7, 8]); } +#[test] +fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() { + // A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent. + for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] { + let mut doc = radio_group_form(); + let medium = ObjRef::new(7, 0); + let mut w = doc.get(medium).as_dict().cloned().unwrap(); + w.remove(b"Parent"); + if let Some(p) = parent.clone() { + w.set(b"Parent".to_vec(), p); + } + doc.set(medium, Object::Dict(w)); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "/Parent {parent:?}"); + let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap(); + assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}"); + assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched"); + assert_eq!(annots(&doc), [6, 8, 10, 11]); + } +} + #[test] fn deleting_a_widget_that_is_not_there_changes_nothing() { let mut doc = radio_group_form(); From cace97e7b2607d31e703bb04c17fa7975f40ec39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:46:00 +0000 Subject: [PATCH 19/35] M4: one text style can't be given to two bookmark levels A line goes to the first level whose style it is in, so the same style for two levels, or one within the size tolerance of the other, left the later level empty without a word. The edit now refuses it, for the dialog and automation alike, naming the two levels (CodeRabbit on #4560). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/engine/src/edit/run.rs | 10 ++ crates/engine/src/tests.rs | 150 ++++++++++++---------- crates/engine/src/text_style_bookmarks.rs | 2 +- 3 files changed, 95 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 8734c372b7..703d1b2cc0 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -296,6 +296,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E if levels.is_empty() { return Err(EditError::Invalid("choose a text style for at least one bookmark level".into())); } + // Each line goes to the first level whose style it is in: a style given again, or one + // within the size tolerance of an earlier level's, would leave its level empty. + for (i, a) in levels.iter().enumerate() { + if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) { + let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2)); + return Err(EditError::Invalid(format!( + "bookmark levels {first} and {second} have the same text style: choose a different one for each level" + ))); + } + } let entries = crate::text_style_bookmarks::headings(doc, levels); if entries.is_empty() { return Err(EditError::Invalid("no text is set in the chosen styles".into())); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5dbcb7d81b..6439ba6363 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1927,6 +1927,24 @@ fn bookmarks_from_text_styles_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style +/// for two levels (or one within the size tolerance of the other) would leave the later level +/// empty. Refused at the edit, for the dialog and automation alike; nothing changes. +#[test] +fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + let near = pdfcraft_engine_style(§ion, section.size + 0.1); + for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] { + let n = levels.len(); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err(); + assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); + } +} + /// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document /// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. #[test] @@ -8849,69 +8867,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/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs index 07a0cacd78..5969188d67 100644 --- a/crates/engine/src/text_style_bookmarks.rs +++ b/crates/engine/src/text_style_bookmarks.rs @@ -24,7 +24,7 @@ pub struct TextStyle { impl TextStyle { /// Whether `other` is this style. Sizes within a quarter point match, as the same heading /// size is often written with rounding differences (12 and 11.96). - fn matches(&self, other: &TextStyle) -> bool { + pub(crate) fn matches(&self, other: &TextStyle) -> bool { self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE } } From 7f33b11ba1bf6f0bb6e2eb10d1b39dca106d7eb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:27:27 +0000 Subject: [PATCH 20/35] M4: the text-style level lists are craft-ui combo boxes Main now refuses egui::ComboBox through clippy (#4988: its menu can't grow back while open, egui#5225), and the merge brought that rule in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/dialogs/bookmark_styles_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs index 890a5f2efd..d022babb6f 100644 --- a/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs @@ -93,7 +93,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.add_enabled_ui(open, |ui| { let Some(slot) = d.levels.get_mut(level) else { return }; let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label); - egui::ComboBox::from_id_salt(("text_style_level", level)) + craft_ui::ComboBox::from_id_salt(("text_style_level", level)) .selected_text(shown) .width(460.0) .show_ui(ui, |ui| { 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 21/35] 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 c14910b182e9dd9d8bf574475b2b64e3b4ff9973 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 11:55:53 +0100 Subject: [PATCH 22/35] docs(ui): a link's tip shows where a press acts on it, with Select or Link Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_cursor.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index d8ae61bc39..382f5efb63 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -6,10 +6,10 @@ //! showed over every tool though only the Select tool follows links, and the I-beam of the text //! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the //! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is -//! that claim's own (a link's target, so only where a press follows the link), or else what lies -//! under the pointer says (a comment, or a signature's validation badge), with any tool, as -//! Acrobat shows a comment's tooltip. A test in `page_pointer/tests.rs` keeps page modules from -//! setting the cursor. +//! that claim's own (a link's target, so only where a press acts on the link: the Select tool +//! follows it, the Link tool edits it), or else what lies under the pointer says (a comment, or a +//! signature's validation badge), with any tool, as Acrobat shows a comment's tooltip. A test in +//! `page_pointer/tests.rs` keeps page modules from setting the cursor. use egui::CursorIcon; From 1e01497649c9ad1c2947b76d4656b0943f8d0b6a Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 12:09:37 +0100 Subject: [PATCH 23/35] fix(ui): Update prepares the unmarked pages once, and shows the thumbnail while one renders The preview cloned and wrote the whole document on the UI thread for every page it stepped to, and showed a blank sheet while the page rendered. Document::without_marks now removes the marks of a kind from every page once per dialog (UnmarkedPages), each page shown renders from it off the UI thread, and the page's thumbnail stays until it arrives. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 18 ++++---- crates/engine/src/text_preview.rs | 58 ++++++++++++++++++-------- crates/ui-egui/src/dialogs/marks_ui.rs | 49 ++++++++++++---------- crates/ui-egui/tests/it/marks.rs | 27 +++++++++--- 5 files changed, 99 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 889b839368..1ec6e57d6d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -66,7 +66,7 @@ pub use links::{ pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; -pub use text_preview::TextEditPreview; +pub use text_preview::{TextEditPreview, UnmarkedPages}; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 248df94d6b..c58ba90129 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3004,13 +3004,14 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { /// Update previews its marks on the page without the ones it replaces: a yellow background /// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. -/// The other kinds of mark and the page's own content stay, a page without that kind has nothing -/// to prepare, and preparing changes nothing in the document. +/// The other kinds of mark and the page's own content stay, every page is prepared at once (the +/// dialog steps through them), a document without that kind has nothing to prepare, and +/// preparing changes nothing in the document. #[test] fn update_previews_on_the_page_without_the_marks_it_replaces() { let (mut s, id) = session_with(2); let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; - s.apply(id, Edit::AddBackground { pages: vec![0], settings: yellow, replace: false, file: None }).unwrap(); + s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap(); let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); let doc = s.get(id).unwrap(); @@ -3022,18 +3023,19 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); - let page = doc.page_without_marks(0, MarkKind::Background).unwrap().expect("page 1 has a background").render_background(1.0).unwrap(); + let unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let page = unmarked.render_page(0, 1.0).unwrap(); assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); assert!(reds(&page) > 50, "the watermark stays"); assert!(dark(&page) > 10, "the page text stays"); - let page = doc.page_without_marks(0, MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_background(1.0).unwrap(); + let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap(); assert_eq!(reds(&page), 0, "without the watermark"); assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); - assert!(doc.page_without_marks(1, MarkKind::Background).unwrap().is_none(), "page 2 has none: its thumbnail is the page"); - assert!(doc.page_without_marks(0, MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); - assert!(doc.page_without_marks(9, MarkKind::Background).is_err(), "no page 10"); + assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); let doc = s.get(id).unwrap(); assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); } diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d19272fcef..5f39945a14 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -19,20 +19,41 @@ impl TextEditPreview { } pub fn render_background(&self, scale: f32) -> Result { - if !scale.is_finite() || scale <= 0.0 { - return Err("preview scale must be finite and positive".into()); - } - guard(|| { - let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); - let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); - if let Some(e) = &out.error { - return Err(e.clone()); - } - Ok(out) - })? + render_one(&self.bytes, &self.config, self.page, scale) } } +/// A document's pages without its marks of one kind ([`Document::without_marks`]). +pub struct UnmarkedPages { + bytes: Arc>, + config: RenderConfig, +} + +impl UnmarkedPages { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + pub fn render_page(&self, page: usize, scale: f32) -> Result { + render_one(&self.bytes, &self.config, page, scale) + } +} + +/// `page` of `bytes` at `scale`, on this thread. +fn render_one(bytes: &Arc>, config: &RenderConfig, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(bytes.clone(), config.clone()); + let out = renderer.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? +} + impl Document { /// Background for retyping an added text item, without changing its saved content. pub fn added_text_preview(&self, page: usize, index: usize) -> Result { @@ -91,21 +112,24 @@ impl Document { })? } - /// The page without its marks of `kind`: what Update replaces them on, so its preview doesn't - /// draw the new marks over the old ones (or tint the old background). `None` when the page has - /// none, and its thumbnail already is that page. Read-only, like [`Self::text_edit_preview`]. - pub fn page_without_marks(&self, page: usize, kind: crate::MarkKind) -> Result, String> { + /// The document without its marks of `kind`, on every page: what Update replaces them on, so + /// its preview doesn't draw the new marks over the old ones (or tint the old background). + /// Made once for all the pages a dialog steps through, as cloning and writing the document + /// costs about a save. `None` when the document has none, and the thumbnails already are + /// those pages. Read-only, like [`Self::text_edit_preview`]. + pub fn without_marks(&self, kind: crate::MarkKind) -> Result, String> { if !self.marks().contains(&kind) { return Ok(None); } guard(|| { let editor = self.editor.as_ref().ok_or("the document can't be read")?; let mut cos = editor.cos.clone(); - if pdfcraft_edit::remove_marks(&mut cos, &[page], kind).map_err(|e| e.to_string())? == 0 { + let n = pdfcraft_model::pages(&cos).len(); + if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::>(), kind).map_err(|e| e.to_string())? == 0 { return Ok(None); } let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(TextEditPreview { page, bytes, config: self.config.clone() })) + Ok(Some(UnmarkedPages { bytes, config: self.config.clone() })) })? } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 62fb3ce33b..2efc10ecce 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -183,26 +183,29 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } -/// In Update, the page previewed without the marks the dialog replaces. Its thumbnail still has -/// them, so the new marks would be drawn over the old ones, and a new background colour would tint -/// the old one. Rendered off the UI thread like the thumbnail, once per page shown. +/// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still +/// have them, so the new marks would be drawn over the old ones, and a new background colour +/// would tint the old one. The document without them is made once per dialog (it costs about a +/// save), and each page shown is rendered from it off the UI thread, like the thumbnails. #[derive(Default)] pub(crate) struct UnmarkedPage { - /// The document, its edit generation, the page and the kind of mark taken away. - key: Option<(pdfcraft_engine::DocId, u64, usize, MarkKind)>, + /// The document, its edit generation and the kind of mark taken away. + key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, renderer: Option, - texture: Option, - tag: Option, + /// The page and scale last asked for. + asked: Option<(usize, u64)>, + /// The page and scale the texture shows. + texture: Option<(usize, u64, egui::TextureHandle)>, } impl UnmarkedPage { pub(crate) fn pending(&self) -> bool { - self.renderer.is_some() && self.texture.is_none() + self.renderer.is_some() && self.asked.is_some() && self.texture.as_ref().map(|(p, t, _)| (*p, *t)) != self.asked } - /// The picture to preview `page` on in place of its thumbnail: `None` when the thumbnail is - /// that page (not Update, or no marks of `kind` on it, or the page can't be prepared), and - /// `Some(None)` while it renders, when the page is a blank sheet. + /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: + /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` is + /// still rendering (its thumbnail shows until then, rather than a blank sheet). fn picture( &mut self, ctx: &egui::Context, @@ -210,39 +213,39 @@ impl UnmarkedPage { page: usize, kind: MarkKind, replace: bool, - ) -> Option> { - let key = replace.then(|| (id, doc.edit_generation(), page, kind)); + ) -> Option { + let key = replace.then(|| (id, doc.edit_generation(), kind)); if self.key != key { *self = Self { key, ..Default::default() }; if replace { - self.renderer = doc.page_without_marks(page, kind).ok().flatten().map(|p| p.renderer()); + self.renderer = doc.without_marks(kind).ok().flatten().map(|p| p.renderer()); } } let renderer = self.renderer.as_ref()?; // At the preview's size in pixels. let p = doc.info.pages.get(page)?; let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); - let tag = (scale * 1000.0) as u64; - if self.tag != Some(tag) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); - self.tag = Some(tag); + let asked = (page, (scale * 1000.0) as u64); + if self.asked != Some(asked) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag: asked.1, ..Default::default() }]); + self.asked = Some(asked); } if let Some(out) = renderer.try_recv() { if out.error.is_some() { - // The thumbnail, as before, rather than no page at all. + // The thumbnails, as before, rather than no page at all. self.renderer = None; self.texture = None; return None; } - if out.request.tag == tag { + if (out.request.page, out.request.tag) == asked { let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - self.texture = Some(ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR)); + self.texture = Some((page, asked.1, ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR))); } } if self.pending() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } - Some(self.texture.as_ref().map(|t| t.id())) + self.texture.as_ref().filter(|(p, ..)| *p == page).map(|(.., t)| t.id()) } } @@ -448,7 +451,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); - let picture = unmarked.unwrap_or_else(|| { + let picture = unmarked.or_else(|| { app.views.get_mut(i).and_then(|view| { view.need_thumbnail(target, true); view.thumb_id(target) diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 994e533a9d..e0d429efa6 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -325,13 +325,13 @@ fn preview_picture(h: &Harness<'static, PdfCraftApp>, uploads: &Uploads) -> Opti let egui::Shape::Mesh(m) = &s.shape else { return None }; let r = m.calc_bounds(); // The size of the 210-point box left of the label (which is centred down its side), and - // not the stepper's arrow beside the label. + // not the stepper's arrow beside the label. A landscape page is shorter than the box, so + // it can sit below the label: within half the box of it. let beside = r.width() > 50.0 && r.height() <= 215.0 && r.right() <= label.left() && label.left() - r.right() < 260.0 - && r.top() <= label.bottom() - && r.bottom() >= label.top(); + && (r.center().y - label.center().y).abs() <= 105.0; if !beside { return None; } @@ -386,6 +386,8 @@ fn update_previews_the_page_without_the_marks_it_replaces() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); + // The Pages panel keeps both thumbnails, for stepping to page 2 in Update below. + app.set_option("panel", "pages").unwrap(); app }); h.run_steps(4); @@ -425,17 +427,30 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.background.update")); h.run_steps(2); h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; - let (tint, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let white = |i: &egui::ColorImage| at(i, 0.5, 0.75) == egui::Color32::WHITE; + let (tint, page) = wait_for_preview(&mut h, &uploads, "the page without its background", white); assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); + // Stepping to page 2 shows its thumbnail until it renders without the background, not a + // blank sheet: the pages are prepared once, and each renders on its own. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().views[0].thumb(1).is_none() { + assert!(std::time::Instant::now() < deadline, "page 2's thumbnail never loaded"); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().marks_draft.preview = Some(1); + h.step(); + assert!(preview_picture(&h, &uploads).is_some(), "a page shows while page 2 renders"); + wait_for_preview(&mut h, &uploads, "page 2 without its background", white); h.get_by_label("Cancel").click(); h.run_steps(2); assert!(h.state_mut().execute("edit.watermark.update")); h.run_steps(2); h.state_mut().marks_draft.wm.text = "NEW".into(); - let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its watermark", |i| count(i, red) == 0); assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); h.get_by_label("Cancel").click(); @@ -443,7 +458,7 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.header_footer.update")); h.run_steps(2); - let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its header", |i| count(i, blue) == 0); assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); } From 098a1f8321722ef194442f47479d6532eeca0dc6 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 12:40:45 +0100 Subject: [PATCH 24/35] fix(ui): Update draws the page shown without its marks at once, and a page that fails alone The preview showed the page's thumbnail, old marks and all, until the unmarked page rendered, and one page that failed to render switched the unmarked preview off for every page. The page shown is now drawn from the unmarked document straight away at the preview's size; the pages a step goes to render ahead off the UI thread; a page that fails falls back to its own thumbnail while the others keep theirs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 2 +- crates/ui-egui/src/dialogs/marks_ui.rs | 96 +++++++++++++++++--------- crates/ui-egui/tests/it/marks.rs | 26 +++---- 3 files changed, 74 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b1abddfdca..dfdb522158 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -307,7 +307,7 @@ impl PdfCraftApp { /// `true` while any open document still waits for page renders (used by headless capture). pub fn render_pending(&self) -> bool { - self.views.iter().any(|v| v.render_pending()) || self.unmarked_page.pending() + self.views.iter().any(|v| v.render_pending()) } } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 2efc10ecce..d9a5b8815d 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -3,6 +3,8 @@ //! page the range selects, with the marks laid out on it. "Update" opens the same dialog in //! replace mode. +use std::collections::{HashMap, HashSet}; + use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; @@ -186,31 +188,35 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { /// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still /// have them, so the new marks would be drawn over the old ones, and a new background colour /// would tint the old one. The document without them is made once per dialog (it costs about a -/// save), and each page shown is rendered from it off the UI thread, like the thumbnails. +/// save). The page shown is drawn from it straight away, at the preview's small size, so neither +/// a blank sheet nor the old marks show while it renders; the pages a step goes to next render +/// ahead, off the UI thread, so stepping rarely waits. #[derive(Default)] pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, + pages: Option, renderer: Option, - /// The page and scale last asked for. - asked: Option<(usize, u64)>, - /// The page and scale the texture shows. - texture: Option<(usize, u64, egui::TextureHandle)>, + /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the + /// pages next to it. + textures: HashMap, + /// Pages that failed to render: their thumbnails show instead, the other pages still don't. + failed: HashSet, + /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step + /// is a frame, which takes what has come back first and draws the rest itself. + ahead: Vec<(usize, u64)>, } impl UnmarkedPage { - pub(crate) fn pending(&self) -> bool { - self.renderer.is_some() && self.asked.is_some() && self.texture.as_ref().map(|(p, t, _)| (*p, *t)) != self.asked - } - /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: - /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` is - /// still rendering (its thumbnail shows until then, rather than a blank sheet). + /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` + /// fails to render. `near` are the pages a step goes to from it, rendered ahead. fn picture( &mut self, ctx: &egui::Context, (doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId), page: usize, + near: &[usize], kind: MarkKind, replace: bool, ) -> Option { @@ -218,34 +224,57 @@ impl UnmarkedPage { if self.key != key { *self = Self { key, ..Default::default() }; if replace { - self.renderer = doc.without_marks(kind).ok().flatten().map(|p| p.renderer()); + self.pages = doc.without_marks(kind).ok().flatten(); + self.renderer = self.pages.as_ref().map(|p| p.renderer()); } } - let renderer = self.renderer.as_ref()?; + let (pages, renderer) = (self.pages.as_ref()?, self.renderer.as_ref()?); // At the preview's size in pixels. - let p = doc.info.pages.get(page)?; - let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); - let asked = (page, (scale * 1000.0) as u64); - if self.asked != Some(asked) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag: asked.1, ..Default::default() }]); - self.asked = Some(asked); - } - if let Some(out) = renderer.try_recv() { + let scale = |page: usize| { + let p = doc.info.pages.get(page)?; + let s = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); + Some((s, (s * 1000.0) as u64)) + }; + let load = |ctx: &egui::Context, out: pdfcraft_render::RenderedPage| { + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR) + }; + while let Some(out) = renderer.try_recv() { + let at = (out.request.page, out.request.tag); + self.ahead.retain(|a| *a != at); if out.error.is_some() { - // The thumbnails, as before, rather than no page at all. - self.renderer = None; - self.texture = None; - return None; - } - if (out.request.page, out.request.tag) == asked { - let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - self.texture = Some((page, asked.1, ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR))); + self.failed.insert(at.0); + } else { + self.textures.insert(at.0, (at.1, load(ctx, out))); } } - if self.pending() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + let (s, tag) = scale(page)?; + if !self.failed.contains(&page) && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) { + match pages.render_page(page, s) { + Ok(out) => { + self.textures.insert(page, (tag, load(ctx, out))); + } + Err(_) => { + self.failed.insert(page); + } + } } - self.texture.as_ref().filter(|(p, ..)| *p == page).map(|(.., t)| t.id()) + // Ahead: the pages a step goes to, not rendered yet at their scale. + let wanted: Vec<(usize, u64)> = near + .iter() + .filter(|p| !self.failed.contains(p)) + .filter_map(|&p| scale(p).map(|(_, t)| (p, t))) + .filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t)) + .collect(); + if !wanted.is_empty() && wanted != self.ahead { + let requests = wanted + .iter() + .filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() })); + renderer.set_queue(requests.collect()); + self.ahead = wanted; + } + self.textures.retain(|p, _| *p == page || near.contains(p)); + self.textures.get(&page).filter(|_| !self.failed.contains(&page)).map(|(_, t)| t.id()) } } @@ -450,7 +479,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M // frame the engine lays the marks out in. Update draws it without the marks it replaces. let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); - let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); + let near: Vec = [false, true].into_iter().filter_map(|forward| step_preview(&d.range, target, count, forward)).collect(); + let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, &near, kind, d.replace); let picture = unmarked.or_else(|| { app.views.get_mut(i).and_then(|view| { view.need_thumbnail(target, true); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index e0d429efa6..f66bf39864 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -386,8 +386,6 @@ fn update_previews_the_page_without_the_marks_it_replaces() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); - // The Pages panel keeps both thumbnails, for stepping to page 2 in Update below. - app.set_option("panel", "pages").unwrap(); app }); h.run_steps(4); @@ -427,30 +425,26 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.background.update")); h.run_steps(2); h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; - let white = |i: &egui::ColorImage| at(i, 0.5, 0.75) == egui::Color32::WHITE; - let (tint, page) = wait_for_preview(&mut h, &uploads, "the page without its background", white); + h.step(); + // Update shows the page without the marks from the frame it opens: neither a blank sheet nor + // the old marks while it renders. + let shown = |h: &Harness<'static, PdfCraftApp>| preview_picture(h, &uploads).expect("a page is previewed"); + let (tint, page) = shown(&h); assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); - // Stepping to page 2 shows its thumbnail until it renders without the background, not a - // blank sheet: the pages are prepared once, and each renders on its own. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - while h.state().views[0].thumb(1).is_none() { - assert!(std::time::Instant::now() < deadline, "page 2's thumbnail never loaded"); - h.step(); - std::thread::sleep(std::time::Duration::from_millis(5)); - } + // So does a step to page 2: the pages are prepared once, and each is drawn as it is shown. h.state_mut().marks_draft.preview = Some(1); h.step(); - assert!(preview_picture(&h, &uploads).is_some(), "a page shows while page 2 renders"); - wait_for_preview(&mut h, &uploads, "page 2 without its background", white); + assert_eq!(at(&shown(&h).1, 0.5, 0.75), egui::Color32::WHITE, "page 2 is previewed without its background at once"); h.get_by_label("Cancel").click(); h.run_steps(2); assert!(h.state_mut().execute("edit.watermark.update")); h.run_steps(2); h.state_mut().marks_draft.wm.text = "NEW".into(); - let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its watermark", |i| count(i, red) == 0); + h.step(); + let (_, page) = shown(&h); assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); h.get_by_label("Cancel").click(); @@ -458,7 +452,7 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.header_footer.update")); h.run_steps(2); - let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its header", |i| count(i, blue) == 0); + let (_, page) = shown(&h); assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); } From dcec1cecfffbbb1bd425e7a41b0bd2abd190dff1 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 13:00:34 +0100 Subject: [PATCH 25/35] fix(ui): Update's preview parses its unmarked copy once and keeps a page's failure to itself The page shown was drawn through a fresh renderer each time, which parsed the whole unmarked document on the UI thread; UnmarkedPages now parses it once and keeps it. An error from rendering a page ahead (the pool retiring its parser, a cancelled request) no longer marks that page failed: only drawing it when shown decides, and a page that has a picture keeps it. On the web, where the pool renders on the UI thread, nothing renders ahead. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 3 +- crates/engine/src/text_preview.rs | 48 ++++++++++++++++++++++++-- crates/ui-egui/src/dialogs/marks_ui.rs | 25 +++++++++----- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c58ba90129..b21f3d335c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3023,7 +3023,7 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); - let unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); let page = unmarked.render_page(0, 1.0).unwrap(); assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); assert!(reds(&page) > 50, "the watermark stays"); @@ -3034,6 +3034,7 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages"); assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); let doc = s.get(id).unwrap(); diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index 5f39945a14..f2e60dac07 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -27,6 +27,12 @@ impl TextEditPreview { pub struct UnmarkedPages { bytes: Arc>, config: RenderConfig, + /// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried + /// again), and kept: parsing costs about as much as reading the document. + parsed: Option, + /// How many times it was parsed. + #[cfg(test)] + builds: u64, } impl UnmarkedPages { @@ -34,8 +40,38 @@ impl UnmarkedPages { RenderPool::new(self.bytes.clone(), 1, self.config.clone()) } - pub fn render_page(&self, page: usize, scale: f32) -> Result { - render_one(&self.bytes, &self.config, page, scale) + /// `page` at `scale`, on this thread. + pub fn render_page(&mut self, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + if self.parsed.is_none() { + self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone())); + #[cfg(test)] + { + self.builds += 1; + } + } + let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) }; + let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() })); + let out = match out { + Ok(out) => out, + Err(e) => { + // A panic may have left it half-way: parse afresh next time. + self.parsed = None; + return Err(e); + } + }; + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + } + + /// How many times [`Self::render_page`] has parsed the document. + #[cfg(test)] + pub(crate) fn parser_builds(&self) -> u64 { + self.builds } } @@ -129,7 +165,13 @@ impl Document { return Ok(None); } let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(UnmarkedPages { bytes, config: self.config.clone() })) + Ok(Some(UnmarkedPages { + bytes, + config: self.config.clone(), + parsed: None, + #[cfg(test)] + builds: 0, + })) })? } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index d9a5b8815d..a3162d0851 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -190,7 +190,8 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { /// would tint the old one. The document without them is made once per dialog (it costs about a /// save). The page shown is drawn from it straight away, at the preview's small size, so neither /// a blank sheet nor the old marks show while it renders; the pages a step goes to next render -/// ahead, off the UI thread, so stepping rarely waits. +/// ahead, off the UI thread (not on the web, where the pool renders on it), so stepping rarely +/// waits. #[derive(Default)] pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. @@ -200,7 +201,8 @@ pub(crate) struct UnmarkedPage { /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the /// pages next to it. textures: HashMap, - /// Pages that failed to render: their thumbnails show instead, the other pages still don't. + /// Pages that failed to render when shown, so they aren't tried every frame: their + /// thumbnails show instead, the other pages still don't. failed: HashSet, /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step /// is a frame, which takes what has come back first and draws the rest itself. @@ -228,7 +230,10 @@ impl UnmarkedPage { self.renderer = self.pages.as_ref().map(|p| p.renderer()); } } - let (pages, renderer) = (self.pages.as_ref()?, self.renderer.as_ref()?); + // On the web the render pool renders inside `try_recv`, on this thread: rendering ahead + // there would draw the pages next to it in the same frame too. + let near = if cfg!(target_arch = "wasm32") { &[][..] } else { near }; + let renderer = self.renderer.as_ref()?; // At the preview's size in pixels. let scale = |page: usize| { let p = doc.info.pages.get(page)?; @@ -242,14 +247,17 @@ impl UnmarkedPage { while let Some(out) = renderer.try_recv() { let at = (out.request.page, out.request.tag); self.ahead.retain(|a| *a != at); - if out.error.is_some() { - self.failed.insert(at.0); - } else { + // An error rendering ahead needn't be the page's (the pool retires its parser, a + // request is cancelled): the page is drawn here when it is shown, and that decides. + if out.error.is_none() { self.textures.insert(at.0, (at.1, load(ctx, out))); } } let (s, tag) = scale(page)?; - if !self.failed.contains(&page) && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) { + if !self.failed.contains(&page) + && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) + && let Some(pages) = self.pages.as_mut() + { match pages.render_page(page, s) { Ok(out) => { self.textures.insert(page, (tag, load(ctx, out))); @@ -274,7 +282,8 @@ impl UnmarkedPage { self.ahead = wanted; } self.textures.retain(|p, _| *p == page || near.contains(p)); - self.textures.get(&page).filter(|_| !self.failed.contains(&page)).map(|(_, t)| t.id()) + // A picture rendered at another scale beats the thumbnail with the old marks. + self.textures.get(&page).map(|(_, t)| t.id()) } } From 1e239b0287183287094875939d8d7b39e6abe5d1 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 13:29:11 +0100 Subject: [PATCH 26/35] fix(ui): a page that fails to render ahead isn't asked again every frame An error from rendering a neighbour ahead left it wanted and unqueued, so each frame queued it again. It is now kept out of rendering ahead at that scale (drawing it when shown still decides). On the web, where nothing renders ahead, no render pool is made; picture()'s doc says a page that fails keeps a picture drawn earlier. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/marks_ui.rs | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index a3162d0851..92af6279a2 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -197,6 +197,8 @@ pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, pages: Option, + /// Renders the pages next to the one shown, ahead. None on the web, where the pool renders on + /// the UI thread inside `try_recv`: it would draw them in the same frame too. renderer: Option, /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the /// pages next to it. @@ -207,12 +209,16 @@ pub(crate) struct UnmarkedPage { /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step /// is a frame, which takes what has come back first and draws the rest itself. ahead: Vec<(usize, u64)>, + /// Pages (at a scale) that came back from rendering ahead with an error, not asked ahead + /// again: the error needn't be the page's, so drawing it when shown still decides. + not_ahead: HashSet<(usize, u64)>, } impl UnmarkedPage { /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` - /// fails to render. `near` are the pages a step goes to from it, rendered ahead. + /// fails to render and was never drawn (a picture of it at another scale is shown rather than + /// the old marks). `near` are the pages a step goes to from it, rendered ahead. fn picture( &mut self, ctx: &egui::Context, @@ -227,13 +233,11 @@ impl UnmarkedPage { *self = Self { key, ..Default::default() }; if replace { self.pages = doc.without_marks(kind).ok().flatten(); - self.renderer = self.pages.as_ref().map(|p| p.renderer()); + self.renderer = self.pages.as_ref().filter(|_| !cfg!(target_arch = "wasm32")).map(|p| p.renderer()); } } - // On the web the render pool renders inside `try_recv`, on this thread: rendering ahead - // there would draw the pages next to it in the same frame too. - let near = if cfg!(target_arch = "wasm32") { &[][..] } else { near }; - let renderer = self.renderer.as_ref()?; + self.pages.as_ref()?; + let near = if self.renderer.is_some() { near } else { &[][..] }; // At the preview's size in pixels. let scale = |page: usize| { let p = doc.info.pages.get(page)?; @@ -244,13 +248,16 @@ impl UnmarkedPage { let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR) }; - while let Some(out) = renderer.try_recv() { + while let Some(out) = self.renderer.as_ref().and_then(|r| r.try_recv()) { let at = (out.request.page, out.request.tag); self.ahead.retain(|a| *a != at); // An error rendering ahead needn't be the page's (the pool retires its parser, a // request is cancelled): the page is drawn here when it is shown, and that decides. + // It isn't asked ahead again, so a page that always fails isn't rendered every frame. if out.error.is_none() { self.textures.insert(at.0, (at.1, load(ctx, out))); + } else { + self.not_ahead.insert(at); } } let (s, tag) = scale(page)?; @@ -272,9 +279,13 @@ impl UnmarkedPage { .iter() .filter(|p| !self.failed.contains(p)) .filter_map(|&p| scale(p).map(|(_, t)| (p, t))) + .filter(|at| !self.not_ahead.contains(at)) .filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t)) .collect(); - if !wanted.is_empty() && wanted != self.ahead { + if let Some(renderer) = self.renderer.as_ref() + && !wanted.is_empty() + && wanted != self.ahead + { let requests = wanted .iter() .filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() })); From 45d95f1107014c81a3a49c3bc760bda3d90050f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:18 -0400 Subject: [PATCH 27/35] 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 28/35] 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 29/35] 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 30/35] 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 31/35] 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 32/35] 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 33/35] 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" From 8f3a4f18bdffe6fcf2fce5d6f20bb0d9a6e91842 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 28 Oct 2026 14:48:29 -0700 Subject: [PATCH 34/35] =?UTF-8?q?M6:=20Organize=20pages=20=E2=96=B8=20Page?= =?UTF-8?q?=20properties=20sets=20the=20selected=20pages'=20tab=20order=20?= =?UTF-8?q?(#4516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M6: Organize pages ▸ Page properties sets the selected pages' tab order Page properties in the Organize pages panel was listed as planned. In the app, a page's tab order could only be set for every page at once, with the form.tab_order commands, which offer no Unspecified. (The form_tab_order tool already set chosen pages, Unspecified included.) - Page properties (page.properties) opens a Tab Order dialog for the selected pages, with Use Row Order, Use Column Order, Use Document Structure and Unspecified. OK writes Edit::SetTabOrder for those pages as one undo step. The dialog opens on the pages' order, or with no choice made when they differ, and then OK is off until one is chosen. OK on the order the pages already have adds no undo step. - Rows and columns run right to left in a document bound on the right, as Use Row Order and Use Column Order are described. PdfCraft reads a right-to-left binding from /ViewerPreferences /Direction /R2L (ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to right. - A page whose /Tabs is an indirect object now takes its row, column or structure order. Before, rank_tabs read only a direct name and ranked the page in annotation order. - pdfcraft_forms::tab_orders and Document::tab_orders read each page's /Tabs. form_tab_order returns them as page_tabs, and reads without changing anything when it is given no order or field. - set_option("dialog", "page-properties") opens the dialog. - Parity: organize.page-tab-order is shipped, and its notes say the Actions tab of Page Properties (organize.page-actions) is still planned; the Organize pages grid notes list Page properties. * Page properties: OK applies only to the document the dialog was opened on The draft keeps the document's id. When another document is active by OK, the dialog closes without changing either document. * Menus: list Page properties… in the Pages menu The menu bar now lists its commands explicitly, and every command with a menu group must appear in it. * Page properties: apply the tab order only to an unchanged document An edit, undo or redo made while the dialog is open can move the pages at the stored indices. OK now closes the dialog without changing a page when the document's edit generation differs from when it opened. * Page properties: say when a document change cancels the tab order OK shows a notice when the document changed or another document became active while the dialog was open, so the user knows to open it again. --- crates/automation/src/forms.rs | 36 ++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 17 +++ crates/engine/src/commands.rs | 1 + crates/engine/src/document.rs | 5 + crates/forms/src/lib.rs | 2 +- crates/forms/src/tab_order.rs | 33 +++++- crates/forms/src/tests.rs | 67 +++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 5 + crates/ui-egui/src/commands.rs | 14 +++ crates/ui-egui/src/dialogs.rs | 23 ++++ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/organize_panel.rs | 28 ++++- crates/ui-egui/src/state.rs | 14 +++ crates/ui-egui/tests/it/editing.rs | 119 ++++++++++++++++++++ parity/acrobat-features.toml | 12 +- 18 files changed, 365 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 71fbc67b3b..b4ce82df59 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -600,17 +600,23 @@ impl Automation { }; let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; return Ok(out); } - let order = match a.str("order")? { - "row" | "rows" => pdfcraft_engine::TabOrder::Row, - "column" | "columns" => pdfcraft_engine::TabOrder::Column, - "structure" => pdfcraft_engine::TabOrder::Structure, - "annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations, - o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + let order = match a.opt_str("order")? { + None => None, + Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row), + Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column), + Some("structure") => Some(pdfcraft_engine::TabOrder::Structure), + Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations), + Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + }; + let mut out = match order { + Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?, + None => json!({}), }; - let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; Ok(out) } @@ -671,6 +677,22 @@ impl Automation { Ok(json!(seq.iter().map(|x| x.1).collect::>())) } + /// Each page's tab order (Page properties ▸ Tab order), in page order. + fn page_tabs(&self, a: &Args) -> Result { + let names: Vec<&str> = self + .doc(a)? + .tab_orders() + .into_iter() + .map(|o| match o { + pdfcraft_engine::TabOrder::Row => "row", + pdfcraft_engine::TabOrder::Column => "column", + pdfcraft_engine::TabOrder::Structure => "structure", + pdfcraft_engine::TabOrder::Annotations => "annotations", + }) + .collect(); + Ok(json!(names)) + } + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { let name = a.str("field")?.to_owned(); if !self.doc(a)?.form.iter().any(|f| f.name == name) { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 78a6805bb3..c4994aace1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -464,7 +464,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") + t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index fb904bacfe..382bda1f4b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4940,6 +4940,23 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +#[test] +fn page_tab_orders_are_read_and_set_per_page_through_tools() { + let dir = workdir("pagetabs"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc })); + assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"])); + assert!(r["undo"].is_null(), "reading changes nothing: {r}"); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] })); + assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"])); + assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true))); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] })); + assert_eq!(r["page_tabs"], json!(["column", "structure", "column"])); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] })); + assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"])); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..2412658240 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -392,6 +392,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 7023caa55e..e66b0d0f17 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -248,6 +248,11 @@ impl Document { self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() } + /// Page properties ▸ Tab order: each page's tab order, in page order. + pub fn tab_orders(&self) -> Vec { + self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 6d8cf9c99d..863376af3f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -46,7 +46,7 @@ pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopte #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; use read::{script, script_budget}; -pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; use values::write_value; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 77234ee094..5b60e3927b 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -4,9 +4,11 @@ use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). + /// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound + /// on the right). Row, - /// Columns, left to right, top to bottom within a column (`/C`). + /// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a + /// document bound on the right). Column, /// The document structure (`/S`; annotation order when the file has none). Structure, @@ -23,6 +25,12 @@ impl TabOrder { _ => TabOrder::Annotations, } } + + /// The tab order of the page at `r`, with an indirect `/Tabs` resolved. + fn of_page(doc: &Document, r: ObjRef) -> TabOrder { + let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o)); + TabOrder::of(tabs.as_deref().and_then(Object::as_name)) + } } /// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). @@ -38,13 +46,20 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } } + // Rows and columns run right to left in a document bound on the right: rank the mirrored + // rectangles there. + if right_to_left(doc) { + for i in &mut items { + i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]]; + } + } let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); for (pi, p) in pages.iter().enumerate() { let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); if on.is_empty() { continue; } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); + let tabs = TabOrder::of_page(doc, *p); match tabs { TabOrder::Row | TabOrder::Column => { let row = tabs == TabOrder::Row; @@ -87,6 +102,13 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } +/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`). +fn right_to_left(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false }; + prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L")) +} + /// Set the tab order of pages (0-based). pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { let refs = page_refs(doc); @@ -104,6 +126,11 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re Ok(()) } +/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified). +pub fn tab_orders(doc: &Document) -> Vec { + page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect() +} + /// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): /// move field `name` one place earlier or later in its page's tab order. The page switches to /// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d8199f15d3..e8ab3b57ea 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1875,6 +1875,73 @@ fn tab_order_follows_the_page_setting() { assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); } +#[test] +fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + let root = doc.root().unwrap(); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"Direction".to_vec(), Object::name("R2L")); + let prefs = doc.add(prefs); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom"); + assert_eq!(order(&reopen(&doc)), "BDAC"); + set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap(); + assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order"); + doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD", "bound on the left"); +} + +#[test] +fn tab_orders_are_read_for_each_page() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(), + "/R".into(), + ]); + use TabOrder::{Annotations, Column, Row, Structure}; + assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified"); + set_tab_order(&mut doc, &[1], Row).unwrap(); + set_tab_order(&mut doc, &[0, 4], Annotations).unwrap(); + let doc = reopen(&doc); + assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]); +} + +#[test] +fn an_indirect_tabs_value_orders_the_page() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let column = doc.add(Object::name("C")); + let page = page_refs(&doc)[0]; + doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap(); + let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + assert_eq!(all.into_iter().map(|x| x.1).collect::(), "ACBD", "columns, as a direct /C orders them"); + assert_eq!(tab_orders(&doc), [TabOrder::Column]); +} + #[test] fn options_tab_flags_alignment_and_defaults() { let mut doc = one_page(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5de2485857..f846b489b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -194,6 +194,7 @@ impl PdfCraftApp { v_pressed: false, attach_override: None, duplicate_draft: None, + page_props_draft: None, field_table_draft: None, form_preview: false, window_title: String::new(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bb5a65cb34..c94e77ffbb 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -120,6 +120,11 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "page-properties" => { + // Same path as the panel, so the selected pages and their order are seeded. + self.execute("page.properties"); + self.dialog + } "none" => None, _ => Some(Dialog::About), } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index acccbcc55a..0889a447d2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -355,6 +355,20 @@ impl PdfCraftApp { } self.dialog = Some(Dialog::NumberPages); } + "page.properties" => { + if let Some(i) = active + && let Some(doc) = self.session.get(self.views[i].id) + { + let pages = self.views[i].target_pages(); + let orders = doc.tab_orders(); + let mut theirs = pages.iter().map(|p| orders.get(*p).copied()); + let first = theirs.next().flatten(); + let before = if theirs.all(|o| o == first) { first } else { None }; + let generation = doc.edit_generation(); + self.page_props_draft = Some(crate::PagePropsDraft { doc: self.views[i].id, generation, pages, before, tab_order: before }); + self.dialog = Some(Dialog::PageProps); + } + } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c21604a4da..2a1c16003c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -52,6 +52,7 @@ struct Pending { summarize_now: bool, optimize_now: bool, duplicate_now: Option, + page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>, replace_now: bool, open_revision: Option, a11y_now: bool, @@ -218,6 +219,19 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } } + Dialog::PageProps => { + let Some(d) = app.page_props_draft.as_mut() else { + req.close = true; + return false; + }; + let (ok, cancel) = crate::organize_panel::page_props_body(ui, d); + // An order every page already has adds no undo step. + if ok && let Some(order) = d.tab_order.filter(|o| d.before != Some(*o)) { + req.page_props_now = Some((d.doc, d.generation, Edit::SetTabOrder { pages: d.pages.clone(), order })); + } + req.close = ok || cancel; + return false; + } Dialog::DuplicateField => { if !dialog_duplicate_field(ui, app, req) { return false; @@ -1643,6 +1657,15 @@ fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { app.duplicate_draft = None; app.apply_edit(e); } + // Another document became active, or the document changed, while the dialog was open: the + // pages at those indices may not be the ones chosen. + if let Some((doc, generation, e)) = req.page_props_now.take() { + if app.active_ids().is_some_and(|(_, id)| id == doc) && app.session.get(doc).is_some_and(|d| d.edit_generation() == generation) { + app.apply_edit(e); + } else { + app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order."); + } + } if req.extract_now { app.extract_selection(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a151f8ecd6..71dd140752 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -127,8 +127,8 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PasswordPrompt, PendingLink, - PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, + CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, + PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -529,6 +529,8 @@ pub struct PdfCraftApp { pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Page Properties: which pages, and the tab order chosen for them. + pub page_props_draft: Option, /// Text field table: the table being set up, and the page it goes on. pub field_table_draft: Option, /// Prepare a form ▸ Preview: fill the form instead of editing its fields. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f58a7d972f..7c5b2a03d7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,6 +190,7 @@ const PAGES: &[&str] = &[ "page.crop", "page.boxes", "page.number", + "page.properties", SEP, "bookmark.add", "bookmark.from_structure", diff --git a/crates/ui-egui/src/panels/organize_panel.rs b/crates/ui-egui/src/panels/organize_panel.rs index 338f64bc14..77f1b250d5 100644 --- a/crates/ui-egui/src/panels/organize_panel.rs +++ b/crates/ui-egui/src/panels/organize_panel.rs @@ -159,7 +159,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if row(ui, t, "tag", tl!("Page labels"), Availability::Ready, false).clicked() { act = Some(Act::Command("page.number")); } - row(ui, t, "info", tl!("Page properties"), Availability::Later, false); + if row(ui, t, "info", tl!("Page properties"), Availability::Ready, false).clicked() { + act = Some(Act::Command("page.properties")); + } }); if close { @@ -182,6 +184,30 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } } +/// Page Properties ▸ Tab Order for the pages it opened on: Use Row Order, Use Column Order, Use +/// Document Structure and Unspecified, then OK and Cancel. OK is off until a choice is made. +/// Returns (OK, Cancel). +pub(crate) fn page_props_body(ui: &mut egui::Ui, d: &mut crate::PagePropsDraft) -> (bool, bool) { + use pdfcraft_engine::TabOrder; + ui.label(egui::RichText::new(tl!("Page Properties")).font(theme::semibold(18.0))); + widgets::section_title(ui, tl!("Tab Order")); + for (order, label) in [ + (TabOrder::Row, tl!("Use Row Order")), + (TabOrder::Column, tl!("Use Column Order")), + (TabOrder::Structure, tl!("Use Document Structure")), + (TabOrder::Annotations, tl!("Unspecified")), + ] { + widgets::radio_value(ui, &mut d.tab_order, Some(order), label); + } + let (mut ok, mut cancel) = (false, false); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ok = ui.add_enabled_ui(d.tab_order.is_some(), |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked(); + cancel = widgets::pill_button(ui, tl!("Cancel"), false).clicked(); + }); + (ok, cancel) +} + /// Whether a row works in this build. #[derive(Clone, Copy, PartialEq, Eq)] enum Availability { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index dd3b3e90aa..d41d511a97 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Organize pages ▸ Page properties (the tab order of the selected pages). + PageProps, /// Protect Using Password. Protect, /// Set Page Boxes (crop, trim, bleed, art, media). @@ -213,6 +215,18 @@ pub struct DuplicateDraft { pub to: usize, } +/// Page Properties: the document and pages it applies to (0-based), the document's edit +/// generation when the dialog opened, and the pages' tab order as they have it (`None` when they +/// differ) and as chosen (`None` until one is chosen). +#[derive(Clone, Debug, PartialEq)] +pub struct PagePropsDraft { + pub doc: DocId, + pub generation: u64, + pub pages: Vec, + pub before: Option, + pub tab_order: Option, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e5e104d674..b181bcb22e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6206,6 +6206,125 @@ fn organize_panel_comes_with_the_grid_and_closes_it() { assert!(h.query_by_label("Page properties").is_none(), "the panel is gone"); } +/// Organize pages ▸ Page properties ▸ Tab Order sets the tab order of the selected pages only, +/// as one undo step, and opens on the order the pages have. +#[test] +fn page_properties_sets_the_tab_order_of_the_selected_pages() { + use pdfcraft_engine::TabOrder::{Annotations, Column, Row}; + let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().tab_orders(); + // The undo and redo labels: a new step replaces the redo step an undo left. + let undo = |h: &Harness<'static, PdfCraftApp>| { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + (doc.can_undo().map(str::to_owned), doc.can_redo().map(str::to_owned)) + }; + let mut h = organize(3); + h.get_by_label("Select page 1").click(); + h.run_steps(2); + h.get_by_label("Select page 3").click(); + h.run_steps(2); + h.get_by_label("Page properties").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::PageProps)); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, Some(Annotations), "opens on Unspecified, as the pages have it"); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!(tabs(&h), [Column, Annotations, Column]); + assert_eq!(undo(&h).0.as_deref(), Some("Set tab order")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(tabs(&h), [Annotations; 3], "one undo step"); + + // Pages with different orders open with no choice made, and OK is off until one is. + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetTabOrder { pages: vec![0], order: Row }); + h.run_steps(2); + h.state_mut().views[0].select_pages(&[0, 1]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, None); + assert!(h.get_by_label("OK").accesskit_node().is_disabled()); + h.get_by_label("Use Row Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(tabs(&h), [Row, Row, Annotations]); + + // OK on the order the pages already have adds no undo step, and Cancel changes nothing. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + let before = (tabs(&h), undo(&h)); + assert_eq!(before.1.1.as_deref(), Some("Set tab order"), "the undone step can be redone"); + h.state_mut().views[0].select_pages(&[2]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!((tabs(&h), undo(&h)), before); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Document Structure").click(); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!((tabs(&h), undo(&h)), before); +} + +/// Page properties sets the tab order of the document it was opened on: when another document +/// is active by OK, neither document changes. +#[test] +fn page_properties_does_not_apply_to_another_active_document() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let first = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.state_mut().open_bytes("other.pdf", None, fixture(3)).unwrap(); + h.run_steps(4); + let other = h.state().views[1].id; + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(other)); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + for id in [first, other] { + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert!(doc.can_undo().is_none()); + } +} + +/// Page properties sets the tab order of the pages it was opened on: when an edit changes the +/// document while the dialog is open, OK closes it without changing a page. +#[test] +fn page_properties_does_not_apply_after_the_document_changes() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let id = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + // Page 1 becomes page 3, so index 0 is another page now. + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert_ne!(doc.can_undo(), Some("Set tab order")); + assert_eq!( + h.state().toast.as_ref().map(|t| t.0.as_str()), + Some("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.") + ); +} + /// Leaving Organize (a page opened from the grid, or the grid's Close) goes back to the /// workspace's panel: under the Edit tab, the Edit panel, not All tools. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d9fbbd1291..15a2ed7d8d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2656,7 +2656,7 @@ status = "shipped" commands = ["page.organize"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/it/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/it/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/it/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/it/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] -notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages, Page labels and Page properties (the selected pages' tab order). Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions and Page templates are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -3240,11 +3240,11 @@ title = "Page tab order property" area = "D" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.properties"] +tools = ["form_tab_order"] +evidence = ["crates/ui-egui/tests/it/editing.rs::page_properties_sets_the_tab_order_of_the_selected_pages", "crates/forms/src/tests.rs::tab_orders_are_read_for_each_page", "crates/forms/src/tests.rs::rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right", "crates/forms/src/tests.rs::an_indirect_tabs_value_orders_the_page", "crates/automation/tests/it/automation.rs::page_tab_orders_are_read_and_set_per_page_through_tools"] +notes = "Organize pages ▸ Page properties ▸ Tab Order sets the selected pages' order: Use Row Order, Use Column Order, Use Document Structure or Unspecified, as one undo step. It opens on the pages' order, or with no choice when they differ. Rows and columns run right to left in a document bound on the right. form_tab_order reads each page's order (page_tabs) and sets it for chosen pages. Acrobat's Page Properties also has an Actions tab (organize.page-actions), still planned." [[feature]] id = "organize.page-actions" From 222a124650b17816ff240215987a4441bcb4b992 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 28 Oct 2026 14:53:03 -0700 Subject: [PATCH 35/35] M5: Import comments takes another PDF and copies its comments onto the same pages (#4595) * M5: Import comments takes another PDF and copies its comments onto the same pages Import comments read only XFDF, FDF, XML, XDP, CSV and text data files. It now also takes a PDF: each comment of that PDF is copied onto the page with the same number, at the same place, with its appearance stream, its pop-up, its replies and its layer. Comments on pages past the document's last page are left out, together with replies to them. Links, form fields and redaction marks are not copied. A comment named like one already on its page replaces it, as an XFDF or FDF import does. A PDF without comments is refused with "nothing in the file matches this document", and a PDF that needs a password is refused. - organize: import_comments copies the comments with the page copier that Insert Pages uses, and registers the layers they use. - engine: Edit::ImportData reads a file that starts with %PDF- as a PDF. Any other file goes to the data importer first, and is read as a PDF when that fails, %PDF- occurs in its first 1,024 bytes and the PDF reader opens it. - ui: Comments > Import comments lists .pdf files; form.import_data lists the same files as before. - automation: the doc_import_data description names the PDF case. - parity: comment.import-from-pdf is shipped. * Import comments: an annotation that is no comment keeps its name A comment imported from a PDF replaced any annotation of the same /NM on its page that was not locked, a redaction mark or a widget, so a link, screen or 3D annotation of that name was overwritten. Only an annotation whose subtype is one of the copied comment subtypes is replaced now; for any other, the copy is added beside it without the name. The subtype is read through an indirect reference. * Import comments: index each page's annotations instead of scanning them per comment * Import comments: a layer that BaseState OFF hides stays hidden * Import comments: a PDF with binary bytes before its header imports on an XFA form, and two comments with one name both land On an XFA form, a decode failure no longer stops the import when a PDF header is in the first 1,024 bytes; the PDF's comments are copied. Two incoming comments on one page with the same /NM no longer replace the same comment: the first replaces it, and the second is added without the name. * Import comments: an inline annotation on the page is replaced by name An annotation written inline in a page's /Annots is made an indirect object before names are matched, so an incoming comment with its /NM replaces it instead of being added beside it. * Import comments: two new comments with one name keep it once When no comment on the page has the name, the first incoming comment keeps its /NM and each later one with the same name is added without it. --- crates/automation/src/tools.rs | 2 +- crates/engine/src/edit.rs | 3 +- crates/engine/src/edit/run.rs | 24 +- crates/engine/src/sources.rs | 18 + crates/engine/src/tests.rs | 64 +++ crates/organize/src/comments.rs | 657 ++++++++++++++++++++++++++ crates/organize/src/import.rs | 18 +- crates/organize/src/import/copy.rs | 12 +- crates/organize/src/lib.rs | 2 + crates/ui-egui/src/commands.rs | 3 +- crates/ui-egui/src/workflows/files.rs | 25 +- crates/ui-egui/tests/it/pickers.rs | 39 ++ parity/acrobat-features.toml | 10 +- 13 files changed, 849 insertions(+), 28 deletions(-) create mode 100644 crates/organize/src/comments.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c67c2ecdbe..f6e60135cd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -511,7 +511,7 @@ pub fn tools() -> Vec { t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), - t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") + t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 2c64f7bcd6..683f2abe8f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -522,7 +522,8 @@ pub enum Edit { links: Vec<(usize, Vec<[f64; 4]>, String)>, style: LinkStyle, }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the + /// comments of another PDF onto the pages with the same numbers. ImportData { name: String, bytes: Arc>, diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 1d02a258a8..d80ea17a13 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -2,6 +2,8 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; +use crate::SourceProblem; +use crate::sources::{comment_source, import_pdf_comments}; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { @@ -664,7 +666,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); } } - Edit::ImportData { bytes, .. } => { + Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => { + let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?; + import_pdf_comments(doc, &src)?; + } + Edit::ImportData { name, bytes } => { // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; // XFDF, FDF, Acrobat's XML and tables go the usual way. let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); @@ -678,9 +684,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); xfa_after_data_change(doc, cx, true)?; } - Some(Err(why)) => return Err(EditError::Invalid(why)), + Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)), _ => { - pdfcraft_xfdf::import(doc, bytes)?; + // A PDF with bytes before its header, within the 1,024 the reader allows, is read + // as a PDF when it is not a data file. A file the PDF reader cannot open keeps + // the data importer's error. + let mut data = doc.clone(); + match pdfcraft_xfdf::import(&mut data, bytes) { + Ok(_) => *doc = data, + Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) { + Ok(src) => import_pdf_comments(doc, &src)?, + Err(SourceProblem::Unreadable(_)) => return Err(e.into()), + Err(p) => return Err(EditError::Source(format!("{name}: {p}"))), + }, + Err(e) => return Err(e.into()), + } } } } diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 3c8083162e..91d6efa3ee 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// Import the comments of `src` onto the pages of `doc` with the same numbers. +pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { + if pdfcraft_organize::import_comments(doc, src)? == 0 { + return Err(pdfcraft_xfdf::DataError::NothingImported.into()); + } + Ok(()) +} + +/// Parse another PDF to import comments from, whatever its permissions allow. +pub(crate) fn comment_source(bytes: &Arc>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) { + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ecaf82efc3..482868b55e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8897,6 +8897,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); } +/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses +/// a PDF that has none. +#[test] +fn importing_a_pdf_copies_its_comments() { + let mut s = Session::new(); + let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap(); + let review = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + ]); + s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap(); + let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::>(); + assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link"); + assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf"); + assert!(s.get(id).unwrap().info.annotations.is_empty()); + + // Bytes before the header, as the reader allows them. + let prefixed = [b"\r\n \n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + assert_eq!(contents(&s), [Some("from the review".to_string())]); + s.undo(id).unwrap(); + + let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]); + let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err(); + assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string()); + // A PDF that needs a password is refused with that reason, with or without bytes before its header. + let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap(); + locked + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap(); + for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] { + let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err(); + assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string()); + } + + // An XFDF file that mentions the PDF header is still read as XFDF. + let xfdf = r#"from the data file"#; + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(contents(&s), [Some("from the data file".to_string())]); +} + +/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of +/// refusing it as a data file in an unknown encoding. +#[test] +fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() { + let data = "
JP
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]); + let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}"); +} + /// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). fn pdf_with_annots(annots: &[&[u8]]) -> Vec { let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs new file mode 100644 index 0000000000..dd5d63ebc4 --- /dev/null +++ b/crates/organize/src/comments.rs @@ -0,0 +1,657 @@ +//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF +//! chosen): each comment is copied onto the page with the same number, at the same place, with +//! its appearance, pop-up and replies. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::import::{Copier, register_layers}; +use crate::{OrganizeError, walk}; + +/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171, +/// except redaction marks (`Redact`) and `Projection`. +const COMMENTS: [&[u8]; 16] = [ + b"Text", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Stamp", + b"Caret", + b"Ink", + b"FileAttachment", + b"Sound", +]; + +/// The Locked annotation flag. +const LOCKED: i64 = 128; + +/// One comment of the source: its entry in the page's `/Annots` and its dictionary. +struct Incoming { + entry: Object, + dict: Dict, +} + +/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of +/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the +/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its +/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are +/// not copied. +/// +/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object +/// so replies to it stay attached. A locked comment, or an annotation of that name that is no +/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it +/// without the name. Returns the number of comments copied. +pub fn import_comments(dst: &mut Document, src: &Document) -> Result { + let from = walk(src)?; + let to = walk(dst)?; + let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); + let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); + let kept = threads_kept(&incoming); + for list in &mut incoming { + list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + } + let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); + // Every comment's object is chosen before any is copied, so a comment that another one reaches + // through `/IRT` is copied into the object chosen for it by name. + let mut lists: Vec> = Vec::new(); + let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new(); + for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() { + if list.is_empty() { + lists.push(None); + continue; + } + let mut annots = PageAnnots::read(dst, *page); + for Incoming { entry, dict } in list { + // The same comment listed twice on a page is copied once. + if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) { + continue; + } + let (target, dict) = reserve(dst, src, &mut annots, dict); + if let Some(r) = entry.as_ref() { + copier.map.insert(r, target); + } + annots.push(target); + planned.push((i, target, dict)); + } + lists.push(Some(annots)); + } + let mut annot_pages = HashMap::new(); + for (i, target, dict) in &planned { + let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?; + dst.set(*target, copy); + annot_pages.insert(*target, pages[*i].1); + } + for (i, target, _) in &planned { + if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup")) + && let Some(annots) = lists[*i].as_mut() + { + annots.push(popup); + annot_pages.insert(popup, pages[*i].1); + } + } + // A pop-up that only its `/Parent` ties to a copied comment comes along too. + for (i, (from, to)) in pages.iter().enumerate() { + let Some(annots) = lists[i].as_mut() else { continue }; + for (popup, parent) in page_popups(src, *from) { + if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) { + continue; + } + let r = dst.add(Object::Null); + copier.map.insert(popup, r); + let copy = copier.copy_value(dst, &src.get(popup), Some(r))?; + dst.set(r, copy); + annots.push(r); + annot_pages.insert(r, *to); + } + } + for ((_, page), annots) in pages.iter().zip(lists) { + if let Some(annots) = annots { + dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?; + } + } + copier.fix_annotations(dst, &annot_pages)?; + register_layers(dst, src, &copier.ocgs)?; + Ok(planned.len()) +} + +/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new +/// object. Returns it with the dictionary to copy. +fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) { + let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text())); + let same = nm.as_deref().and_then(|nm| annots.named(dst, nm)); + // A second incoming comment with the name of one added earlier is added without the name. + let taken = nm.is_some_and(|nm| !annots.added.insert(nm)); + let target = match same { + None if taken => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + Some((r, false)) => { + annots.claimed.insert(r); + // The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent` + // is the comment. + if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) { + annots.remove(p); + } + for p in annots.popups.get(&r).cloned().unwrap_or_default() { + annots.remove(p); + } + r + } + Some((_, true)) => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + None => dst.add(Object::Null), + }; + (target, dict) +} + +/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one +/// only with the comment it answers, up to the first comment of the thread. A thread whose replies +/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. +fn threads_kept(incoming: &[Vec]) -> HashSet { + // For each comment: no parent, a parent comment, or a parent that is no comment of the source. + let parents: HashMap>> = + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + let mut known: HashMap = HashMap::new(); + for &start in parents.keys() { + let (mut path, mut on_path) = (Vec::new(), HashSet::new()); + let mut at = start; + let kept = loop { + if let Some(&k) = known.get(&at) { + break k; + } + if !on_path.insert(at) { + break true; + } + match parents.get(&at) { + None => break false, + Some(None) => { + path.push(at); + break true; + } + Some(Some(None)) => { + path.push(at); + break false; + } + Some(Some(Some(parent))) => { + path.push(at); + at = *parent; + } + } + }; + for r in path { + known.insert(r, kept); + } + } + known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect() +} + +/// The comments in `page`'s `/Annots`, in order. +fn page_comments(src: &Document, page: ObjRef) -> Vec { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .into_iter() + .filter_map(|entry| { + let dict = src.resolve(&entry).as_dict().cloned()?; + let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) + }) + .collect() +} + +/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`). +fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .iter() + .filter_map(|entry| { + let r = entry.as_ref()?; + let d = src.get(r).as_dict().cloned()?; + (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + }) + .collect() +} + +/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it +/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a +/// redaction mark or a form widget. +/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not +/// scan the whole list. +struct PageAnnots { + list: Vec, + members: HashSet, + /// The annotations on the page before the import, by `/NM`, in list order. + names: HashMap>, + /// The pop-ups on the page before the import, by `/Parent`. + popups: HashMap>, + removed: HashSet, + /// The annotations that an incoming comment has already replaced. + claimed: HashSet, + /// The names of the incoming comments placed so far. + added: HashSet, +} + +impl PageAnnots { + /// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects. + fn read(dst: &mut Document, page: ObjRef) -> Self { + let list: Vec = dst + .get(page) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| dst.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let list: Vec = list + .into_iter() + .map(|a| match a { + Object::Dict(_) => Object::Ref(dst.add(a)), + a => a, + }) + .collect(); + let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::>::new(), HashMap::>::new()); + for r in list.iter().filter_map(Object::as_ref) { + members.insert(r); + let Some(d) = dst.get(r).as_dict().cloned() else { continue }; + if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { + names.entry(name).or_default().push(r); + } + if d.name(b"Subtype") == Some(b"Popup".as_slice()) + && let Some(parent) = d.reference(b"Parent") + { + popups.entry(parent).or_default().push(r); + } + } + PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() } + } + + /// Add `r` at the end, unless the page already lists it. + fn push(&mut self, r: ObjRef) { + if self.removed.remove(&r) { + self.list.retain(|a| a.as_ref() != Some(r)); + } + if self.members.insert(r) { + self.list.push(Object::Ref(r)); + } + } + + /// Take every entry for `r` off the page. + fn remove(&mut self, r: ObjRef) { + if self.members.remove(&r) { + self.removed.insert(r); + } + } + + /// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or + /// it is no comment. + fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> { + let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r)); + let first = *listed.clone().next()?; + // A second incoming comment with the name of one already replaced is added beside it. + let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) }; + let d = dst.get(r).as_dict().cloned()?; + let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0); + let subtype = d.get(b"Subtype").map(|s| dst.resolve(s)); + let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s)); + Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT"))) + } + + /// The new `/Annots`. + fn into_list(self) -> Vec { + let removed = self.removed; + self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{SaveOptions, write_full}; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + /// The reviewer's copy: three pages of comments, a link and a pop-up among them. + fn reviewed() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>", + "<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream", + "<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>", + ]) + } + + /// The document receiving the comments: two pages, three comments of its own on page 1. + fn original() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>", + ]) + } + + fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> { + let (p, _) = walk(doc).unwrap()[page].clone(); + let list = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default(); + list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect() + } + + fn text(d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text()) + } + + fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) { + list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}")) + } + + #[test] + fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() { + let mut doc = original(); + let src = reviewed(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2"); + let pages: Vec = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect(); + let one = page_annots(&doc, 0); + for (_, d) in &one { + assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on"); + } + + // The note named like one already there replaces it in place, with its new pop-up. + let (note, d) = by_contents(&one, "from the reviewer"); + assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object"); + let popup = d.reference(b"Popup").unwrap(); + assert_ne!(popup, ObjRef::new(8, 0)); + assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note)); + let popups: Vec = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect(); + assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once"); + assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note"); + + // The locked comment of that name stays; the copy is added beside it, unnamed. + assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked")); + assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None); + assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay"); + + // The stamp keeps its appearance; the redaction mark is not copied. + let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap(); + let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied"); + assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice()))); + + // Page 3 is past the document's last page: its square and the reply to it are left out. + assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3"))); + assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up"); + + // Page 2 takes the highlight, not the link. + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 1); + assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice())); + assert_eq!(two[0].1.reference(b"P"), Some(pages[1])); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same"); + } + + #[test] + fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() { + // Page 1 holds a reply to a note on page 2, named like the document's own note there. + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>", + // A pop-up that only its /Parent ties to the note. + "<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + // The same name in UTF-16BE, and a pop-up the note does not name. + "<< /Type /Annot /Subtype /Text /NM /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes"); + assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer"))); + assert_ne!(two[1].0, ObjRef::new(6, 0)); + assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0))); + assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true))); + assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0)); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1); + assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note"); + } + + #[test] + fn a_comment_keeps_its_layer_and_the_layer_its_visibility() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)]))); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + } + + #[test] + fn a_layer_hidden_by_the_base_state_stays_hidden() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![]))); + } + + #[test] + fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() { + // Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3, + // which the document has no page for. Then two replies that answer each other. + let n = 20_000; + let first = 6; + let mut bodies: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()]; + let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect(); + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>")); + bodies.push("<< /Type /Page /Parent 2 0 R >>".into()); + let square = first + n + 2; + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>")); + for o in first..first + n { + let parent = if o + 1 == first + n { square } else { o + 1 }; + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>")); + } + let (a, b) = (first + n, first + n + 1); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into()); + let src = build(&bodies.iter().map(String::as_str).collect::>()); + let mut doc = original(); + let started = std::time::Instant::now(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + let one = page_annots(&doc, 0); + let (one_ref, one_dict) = by_contents(&one, "one"); + let (two_ref, two_dict) = by_contents(&one, "two"); + assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref))); + } + + #[test] + fn only_a_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>", + ]); + // A link and a screen annotation named like two of the comments, and a comment whose + // subtype is an indirect object. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>", + "/Text", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 3); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced"); + let (note, d) = by_contents(&one, "newer text"); + assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place"); + assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice()))); + assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1")); + assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice()))); + assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1")); + assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name"); + assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None); + } + + #[test] + fn two_comments_with_one_name_replace_one_comment_and_add_the_other() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2, "one comment replaced and one added"); + let (first, d) = by_contents(&one, "first"); + assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin"))); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn two_new_comments_with_one_name_keep_it_once() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2); + assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin")); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn an_inline_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 1); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy"); + assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline")); + } + + #[test] + fn a_pdf_without_comments_copies_none() { + let mut doc = original(); + let before = page_annots(&doc, 0).len(); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); + assert_eq!(page_annots(&doc, 0).len(), before); + } +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index bd1878c3bc..9f86d6455a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -34,8 +34,8 @@ pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, }; -pub(crate) use copy::copy_object; -use copy::{Copier, SKIP_ON_PAGE}; +use copy::SKIP_ON_PAGE; +pub(crate) use copy::{Copier, copy_object}; use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; pub use fields::{combine_field_conflicts, field_name_conflicts}; @@ -432,18 +432,22 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: /// Register copied optional content groups in the destination's `/OCProperties`, keeping each /// layer's default visibility from the source. -fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { +pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { if ocgs.is_empty() { return Ok(()); } - let src_off: Vec = src + let src_config: Dict = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o))) .and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d))) - .and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o))) - .and_then(|o| o.as_array().cloned()) + .and_then(|d| d.as_dict().cloned()) .unwrap_or_default(); + let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF")); + // With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101). + let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice())); + let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r)); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; let (props_ref, mut props) = match catalog.get(b"OCProperties") { @@ -462,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] } all.push(r.clone()); order.push(r.clone()); - if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } + if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } } props.set(b"OCGs".to_vec(), Object::Array(all)); config.set(b"ON".to_vec(), Object::Array(on)); diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f4b5499625..5e4be54150 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -43,11 +43,11 @@ pub(super) const WIDGET_KEYS: &[&[u8]] = &[ b"AF", ]; -pub(super) struct Copier<'a> { +pub(crate) struct Copier<'a> { pub(super) src: &'a Document, /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. depth: usize, - pub(super) map: HashMap, + pub(crate) map: HashMap, /// Source page → destination page, for rewriting `/P` and destinations. pub(super) pages: HashMap, /// Destination objects that are annotation dictionaries (fixed up after copying). @@ -55,11 +55,11 @@ pub(super) struct Copier<'a> { /// Top-level form fields reached through widgets. pub(super) fields: Vec, /// Optional content groups copied: (source, destination). - pub(super) ocgs: Vec<(ObjRef, ObjRef)>, + pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, } impl<'a> Copier<'a> { - pub(super) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } } @@ -89,7 +89,7 @@ impl<'a> Copier<'a> { Ok(()) } - pub(super) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { return Ok(o.clone()); } @@ -163,7 +163,7 @@ impl<'a> Copier<'a> { } /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - pub(super) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { for &a in &std::mem::take(&mut self.annots) { let src_dict = dst.get(a).as_dict().cloned(); let Some(d) = src_dict else { continue }; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index e24056a9c3..b890965d0c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -15,6 +15,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; mod attachments; mod boxes; +mod comments; mod dedupe; mod import; mod labels; @@ -27,6 +28,7 @@ mod xmp; pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; +pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 07a36ac7f4..d8c51b0e97 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -543,7 +543,8 @@ impl PdfCraftApp { } "edit.links_from_urls" => self.links_from_urls(), "edit.remove_links" => self.remove_all_links(), - "comment.import" | "form.import_data" => self.import_data_dialog(), + "comment.import" => self.import_comments_dialog(), + "form.import_data" => self.import_data_dialog(), "comment.stamp" => { self.left = crate::LeftPanel::Tool("stamp"); self.reveal_tools(); diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index 0b031c8a8a..a9313a4714 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -868,10 +868,24 @@ pub(crate) fn strip_pdf(name: &str) -> &str { } impl PdfCraftApp { - /// Comments ▸ Import comments / Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text. + /// Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text. pub fn import_data_dialog(&mut self) { + self.import_dialog(false); + } + + /// Comments ▸ Import comments: the files Import data takes, or another PDF to copy the + /// comments of. + pub fn import_comments_dialog(&mut self) { + self.import_dialog(true); + } + + fn import_dialog(&mut self, pdf: bool) { #[cfg(not(target_arch = "wasm32"))] { + let mut extensions = vec!["xfdf", "fdf", "xml", "xdp", "csv", "txt"]; + if pdf { + extensions.insert(2, "pdf"); + } let import = |app: &mut Self, path: std::path::PathBuf| match std::fs::read(&path) { Ok(bytes) => { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -880,11 +894,11 @@ impl PdfCraftApp { Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), }; match self.save_override.clone() { - Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), + Some(p) if extensions.iter().any(|e| p.ends_with(&format!(".{e}"))) => import(self, p.into()), Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"]) + .add_filter(tl!("Comment and form data").to_string(), &extensions) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -892,7 +906,10 @@ impl PdfCraftApp { } } #[cfg(target_arch = "wasm32")] - self.notify_tr("Importing data arrives on the web with file pickers for data files"); + { + let _ = pdf; + self.notify_tr("Importing data arrives on the web with file pickers for data files"); + } } /// Export all comments / form data: the format follows the file name's extension. diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index f9e46c5f2f..330cd50667 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -376,6 +376,45 @@ fn export_comments_writes_on_a_later_frame() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn import_comments_takes_the_comments_of_another_pdf() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + ]; + let mut review = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(review.len()); + review.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = review.len(); + review.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + review.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + review.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = scratch("import-pdf-comments"); + let path = dir.join("review.pdf"); + std::fs::write(&path, review).unwrap(); + let mut h = harness(&[1]); + // Through the save path a test uses in place of the picker, and through the picker. + for chosen in [Some(path.to_string_lossy().into_owned()), None] { + h.state_mut().save_override = chosen.clone(); + h.state_mut().pick_override = chosen.is_none().then(|| vec![path.to_string_lossy().into_owned()]); + h.state_mut().run_command("comment.import"); + h.run_steps(2); + let app = h.state(); + let contents: Vec> = app.session.get(app.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert_eq!(contents, [Some("from the review".to_string())], "the review's comment is imported once ({chosen:?})"); + h.state_mut().run_command("edit.undo"); + h.run_steps(1); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn extracted_pages_are_not_deleted_if_the_document_was_edited_while_choosing_the_folder() { let dir = scratch("extract-edited"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabeb5421a..7e96dfa190 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4264,11 +4264,11 @@ title = "Import comments from another PDF" area = "E" tier = "P2" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.import"] +tools = ["doc_import_data"] +evidence = ["crates/organize/src/comments.rs::comments_of_another_pdf_land_on_the_pages_with_the_same_numbers", "crates/organize/src/comments.rs::a_reply_on_an_earlier_page_reaches_its_comment_by_name", "crates/organize/src/comments.rs::a_comment_keeps_its_layer_and_the_layer_its_visibility", "crates/organize/src/comments.rs::a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays", "crates/organize/src/comments.rs::a_pdf_without_comments_copies_none", "crates/engine/src/tests.rs::importing_a_pdf_copies_its_comments", "crates/ui-egui/tests/it/pickers.rs::import_comments_takes_the_comments_of_another_pdf"] +notes = "Import comments accepts a PDF beside XFDF and FDF. Each comment goes onto the page with the same number, at the same place, with its appearance, pop-up and replies; comments on pages past the document's last page are left out. A same-named comment is replaced, as with XFDF and FDF. Not yet: redaction marks are not copied, and a PDF that needs a password is refused." [[feature]] id = "comment.summarize"