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 001/120] 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 002/120] 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 003/120] 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 004/120] 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 005/120] 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 006/120] 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 007/120] 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 008/120] 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 009/120] 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 010/120] 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 011/120] 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 012/120] 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 013/120] 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 014/120] 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 015/120] 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 086339b7d7cd65f636e06ed2dfc1f0704310354a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 10:53:23 -0400 Subject: [PATCH 016/120] fix(render): keep the text matrix across Q inside a text object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored hayro-interpret kept the text and text line matrices in the saved graphics state, so a `q … Q` inside BT … ET put the pen back where it was before `q`. Text after the `Q` was drawn, extracted and found at the wrong place, and search redaction refused to apply because its find boxes (from the render text layer) disagreed with the redactor's own measurement. Tm and Tlm are not graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1); q/Q inside a text object is allowed in PDF 2.0 (§8.2). Patch (67) carries them across the restore; font and the other text parameters are still restored. Forms and annotations save and restore around their own content, and Type 3 glyphs, patterns and soft masks run in a context of their own, so a stream's pen does not reach its caller unless the stream pops past its own saves (two or more stray Q in a form already corrupt the page state on main; unchanged here). Tests: pdfcraft-render it::text_matrix_q (three behaviour tests that fail with the fix reverted, two guards) and pdfcraft-automation it::redact_text_after_q (end to end, fails at redact_apply when reverted). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/main.rs | 1 + .../tests/it/redact_text_after_q.rs | 66 +++++++++ crates/render/tests/it/main.rs | 1 + crates/render/tests/it/text_matrix_q.rs | 129 ++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 15 +- 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 crates/automation/tests/it/redact_text_after_q.rs create mode 100644 crates/render/tests/it/text_matrix_q.rs diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d94aa9f7c4..108bcd02cb 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,3 +8,4 @@ mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; +mod redact_text_after_q; diff --git a/crates/automation/tests/it/redact_text_after_q.rs b/crates/automation/tests/it/redact_text_after_q.rs new file mode 100644 index 0000000000..5a30354e1c --- /dev/null +++ b/crates/automation/tests/it/redact_text_after_q.rs @@ -0,0 +1,66 @@ +//! Search-and-redact of text that follows a `q`/`Q` inside a text object. Find's boxes come from +//! the renderer's text and redaction measures the content itself, so the two must agree on where +//! the text after `Q` is (vendored hayro-interpret patch 67); when they disagreed, `redact_apply` +//! refused to change anything. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +const CONTENT: &str = "BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET"; + +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \ + /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET"), "{after:?}"); + assert!(after.contains("AAAAB"), "the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..d376ca7e44 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -8,5 +8,6 @@ mod named_form_properties; mod search; mod separation_all; mod substitute_widths; +mod text_matrix_q; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/text_matrix_q.rs b/crates/render/tests/it/text_matrix_q.rs new file mode 100644 index 0000000000..5a1303a17a --- /dev/null +++ b/crates/render/tests/it/text_matrix_q.rs @@ -0,0 +1,129 @@ +//! Contributor-original synthetic pages: `q`/`Q` inside a text object. The text matrices are not +//! part of the graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` restores the font +//! but not the pen (vendored hayro-interpret patch 67). Each case is paired with a page that +//! changes the font back by hand instead, which must come out the same. +use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +/// `q`/`Q` around a larger font: the text after `Q` continues from the end of the "B". +const SAVED: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET"; +/// The same text with the font set back by `Tf`. +const BY_HAND: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj /F2 24 Tf (B) Tj /F1 12 Tf (SECRET) Tj ET"; +/// A move inside `q`/`Q` moves the pen for good: `Td` is not undone by `Q`. +const MOVED_IN_SAVE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET"; +const MOVED: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj 100 0 Td (SECRET) Tj ET"; +/// A form that moves its own pen and ends with an unbalanced `Q`, shown inside a text object +/// (`Do` is not allowed there, but readers draw it). +const FORM_INSIDE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj /X Do (SECRET) Tj ET"; +const FORM_BODY: &[u8] = b"BT /F1 12 Tf 200 0 Td ET Q"; +const NO_FORM: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj (SECRET) Tj ET"; + +/// One page per entry of `contents`, 400 × 400, with Helvetica as /F1, Courier as /F2 and the +/// form /X. +fn pdf(contents: &[&str]) -> Arc> { + let n = contents.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", 5 + 2 * i)).collect(); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(), + b"<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> /XObject << /X 4 0 R >> >>".to_vec(), + stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", FORM_BODY), + ]; + for (i, content) in contents.iter().enumerate() { + objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", 6 + 2 * i).into_bytes()); + objects.push(stream(b"", content.as_bytes())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Arc::new(out) +} + +fn stream(dict: &[u8], body: &[u8]) -> Vec { + let mut s = b"<< ".to_vec(); + s.extend_from_slice(dict); + s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes()); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn request(page: usize, kind: RequestKind) -> RenderRequest { + RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false } +} + +fn texts(contents: &[&str]) -> Vec> { + let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default()); + (0..contents.len()) + .map(|page| { + let out = renderer.render(request(page, RequestKind::Text)); + assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error); + out.text.expect("page text") + }) + .collect() +} + +/// The boxes of the glyphs of `word`'s one match on the page. +fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> { + let hits = text.find(word); + assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text()); + hits[0].clone().map(|i| text.glyphs[i].rect).collect() +} + +fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) { + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b) { + assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}"); + } +} + +#[test] +fn text_after_q_q_continues_where_the_text_inside_left_off() { + let pages = texts(&[SAVED, BY_HAND]); + let secret = boxes(&pages[0], "SECRET"); + assert_same_boxes(&secret, &boxes(&pages[1], "SECRET")); + let b = boxes(&pages[0], "B"); + assert!(secret[0][0] >= b[0][2] - 0.01, "SECRET starts at {} inside the B ending at {}", secret[0][0], b[0][2]); +} + +#[test] +fn q_q_still_restores_the_font() { + let pages = texts(&[SAVED, BY_HAND]); + let height = |r: [f32; 4]| r[3] - r[1]; + let secret = boxes(&pages[0], "SECRET"); + let b = boxes(&pages[0], "B"); + assert!(height(secret[0]) < height(b[0]) * 0.75, "SECRET is drawn at the 12 pt font, not the B's 24 pt"); +} + +#[test] +fn a_move_inside_q_q_is_kept() { + let pages = texts(&[MOVED_IN_SAVE, MOVED]); + assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET")); +} + +#[test] +fn a_forms_unbalanced_q_does_not_move_the_page_text() { + let pages = texts(&[FORM_INSIDE, NO_FORM]); + assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET")); +} + +#[test] +fn q_q_inside_a_text_object_draws_like_the_font_set_back_by_hand() { + let mut renderer = PageRenderer::new(pdf(&[SAVED, BY_HAND]), RenderConfig::default()); + let saved = renderer.render(request(0, RequestKind::Pixels)); + let by_hand = renderer.render(request(1, RequestKind::Pixels)); + assert!(saved.error.is_none() && by_hand.error.is_none()); + assert_eq!((saved.width, saved.height), (by_hand.width, by_hand.height)); + assert!(saved.rgba == by_hand.rgba, "the two pages draw differently"); +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..59b2d98b42 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (67) `Q` inside a text object no longer restores the text and text line matrices (ISO 32000-2 §8.4.1 Table 51, §9.4.1: they are not graphics state): text after `q … Q` was drawn, extracted and found back at the position from before `q`, so search redaction marked the wrong place and refused to apply | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render), `it::text_matrix_q::{text_after_q_q_continues_where_the_text_inside_left_off, a_move_inside_q_q_is_kept, q_q_inside_a_text_object_draws_like_the_font_set_back_by_hand, q_q_still_restores_the_font, a_forms_unbalanced_q_does_not_move_the_page_text}` (pdfcraft-render), `it::redact_text_after_q::find_and_redact_removes_text_after_a_q_q_inside_its_text_object` (pdfcraft-automation) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index cf71eb92b5..a890616dc0 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -637,7 +637,20 @@ fn interpret_window<'a>( TypedInstruction::ClipEvenOdd(_) => { *(context.clip_mut()) = Some(FillRule::EvenOdd); } - TypedInstruction::RestoreState(_) => context.restore_state(device), + TypedInstruction::RestoreState(_) => { + // PdfCraft patch (67): the text and text line matrices are not part of the + // graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` inside a text + // object keeps the pen where the text before it left it. Outside one, the next + // `BT` resets both. Forms and annotations run between a save and restore of their + // own, and Type 3 glyphs, patterns and soft masks in a context of their own, so a + // stream's pen does not reach its caller unless the stream pops past its own + // saves, which already hands the caller a wrong state. + let text = &context.get().text_state; + let pen = (text.text_matrix, text.text_line_matrix); + context.restore_state(device); + let text = &mut context.get_mut().text_state; + (text.text_matrix, text.text_line_matrix) = pen; + } TypedInstruction::FlatnessTolerance(_) => { // Ignore for now. } From bac061e4731f6541a96f505e5d814664c9f066a2 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 11:34:17 -0400 Subject: [PATCH 017/120] fix(render): a form's stray `Q` no longer pops the page's saved states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A form XObject or annotation appearance with more `Q` than `q` popped graphics states saved by the content that drew it, so later page text, paint and clip lost the page's `cm` and clip, and search redaction of that text failed closed at `redact_apply`. A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2, §8.10.1). Vendored hayro-interpret patch (68): `interpret()` records the state depth at which its stream starts (`Context::enter_stream`) and restores the outer floor on exit; a `Q` at the floor is ignored with a warning. Page content (`interpret_content`) is unchanged. Tests: pdfcraft-render `it::stray_q_in_a_form` (three behaviour tests fail with the fix reverted, one guard) and pdfcraft-automation `it::redact_after_stray_q_form` (end to end; fails at `redact_apply` with the fix reverted). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/main.rs | 1 + .../tests/it/redact_after_stray_q_form.rs | 67 +++++++++ crates/render/tests/it/main.rs | 1 + crates/render/tests/it/stray_q_in_a_form.rs | 141 ++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/context.rs | 24 +++ vendor/hayro-interpret/src/interpret/mod.rs | 8 + 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 crates/automation/tests/it/redact_after_stray_q_form.rs create mode 100644 crates/render/tests/it/stray_q_in_a_form.rs diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d94aa9f7c4..cba45c9e16 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,3 +8,4 @@ mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; +mod redact_after_stray_q_form; diff --git a/crates/automation/tests/it/redact_after_stray_q_form.rs b/crates/automation/tests/it/redact_after_stray_q_form.rs new file mode 100644 index 0000000000..a91f872750 --- /dev/null +++ b/crates/automation/tests/it/redact_after_stray_q_form.rs @@ -0,0 +1,67 @@ +//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes +//! come from the renderer's text and redaction measures the content itself, so the two must agree +//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \ + BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q"; +const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q"; + +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET"), "{after:?}"); + assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..9e949b9182 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -7,6 +7,7 @@ mod luminosity_group_space; mod named_form_properties; mod search; mod separation_all; +mod stray_q_in_a_form; mod substitute_widths; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/stray_q_in_a_form.rs b/crates/render/tests/it/stray_q_in_a_form.rs new file mode 100644 index 0000000000..33ace89f37 --- /dev/null +++ b/crates/render/tests/it/stray_q_in_a_form.rs @@ -0,0 +1,141 @@ +//! Contributor-original synthetic pages: a form XObject whose content has more `Q` than `q`. +//! A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2, §8.10.1), so a `Q` with +//! nothing of its own stream to restore is ignored and cannot pop the states of the page that +//! drew the form (vendored hayro-interpret patch 68). Each case is paired with the same page +//! drawing a balanced form, which must come out the same. +use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +/// Forms /B (balanced), /S2 (two stray `Q`s) and /S3 (three), each moving its own pen. +const FORMS: [(&str, &[u8]); 3] = + [("B", b"q BT /F1 12 Tf 200 0 Td ET Q"), ("S2", b"BT /F1 12 Tf 200 0 Td ET Q Q"), ("S3", b"BT /F1 12 Tf 200 0 Td ET Q Q Q")]; + +/// A page that shifts and clips inside `q … Q`, draws form `form` there, then text and a fill +/// that depend on the shift and the clip, and after its `Q` text that depends on neither. +fn page(form: &str, in_text: bool) -> String { + let draw = if in_text { + format!("BT /F1 12 Tf 72 300 Td (AA) Tj /{form} Do (SECRET) Tj ET") + } else { + format!("BT /F1 12 Tf 72 300 Td (AA) Tj ET /{form} Do BT /F1 12 Tf 72 280 Td (SECRET) Tj ET") + }; + format!("q 1 0 0 1 20 -100 cm 0 0 200 400 re W n {draw} 0 0 1 rg 0 0 400 50 re f Q BT /F1 12 Tf 72 360 Td (TAIL) Tj ET") +} + +/// One 400 × 400 page per entry of `contents`, with Helvetica as /F1 and the forms of `FORMS`. +fn pdf(contents: &[String]) -> Arc> { + let first_page = 4 + FORMS.len(); + let n = contents.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", first_page + 2 * i)).collect(); + let xobjects: String = FORMS.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 4 + i)).collect(); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(), + format!("<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << {xobjects}>> >>").into_bytes(), + ]; + for (_, body) in FORMS { + objects.push(stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", body)); + } + for (i, content) in contents.iter().enumerate() { + objects.push( + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", first_page + 1 + 2 * i).into_bytes(), + ); + objects.push(stream(b"", content.as_bytes())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Arc::new(out) +} + +fn stream(dict: &[u8], body: &[u8]) -> Vec { + let mut s = b"<< ".to_vec(); + s.extend_from_slice(dict); + s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes()); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn request(page: usize, kind: RequestKind) -> RenderRequest { + RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false } +} + +fn texts(contents: &[String]) -> Vec> { + let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default()); + (0..contents.len()) + .map(|page| { + let out = renderer.render(request(page, RequestKind::Text)); + assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error); + out.text.expect("page text") + }) + .collect() +} + +/// The boxes of the glyphs of `word`'s one match on the page. +fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> { + let hits = text.find(word); + assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text()); + hits[0].clone().map(|i| text.glyphs[i].rect).collect() +} + +fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) { + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b) { + assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}"); + } +} + +fn assert_text_like_the_balanced_form(in_text: bool) { + let pages = texts(&[page("B", in_text), page("S2", in_text), page("S3", in_text)]); + for (stray, name) in pages[1..].iter().zip(["S2", "S3"]) { + for word in ["SECRET", "TAIL"] { + let (got, want) = (boxes(stray, word), boxes(&pages[0], word)); + assert_eq!(got.len(), want.len(), "{name}: {word}"); + assert_same_boxes(&got, &want); + } + } +} + +#[test] +fn a_forms_stray_q_does_not_undo_the_pages_cm() { + assert_text_like_the_balanced_form(false); +} + +#[test] +fn a_forms_stray_q_inside_a_text_object_does_not_undo_the_pages_cm() { + assert_text_like_the_balanced_form(true); +} + +#[test] +fn a_forms_stray_q_does_not_undo_the_pages_clip() { + let contents = [page("B", false), page("S2", false), page("S3", false)]; + let mut renderer = PageRenderer::new(pdf(&contents), RenderConfig::default()); + let balanced = renderer.render(request(0, RequestKind::Pixels)); + assert!(balanced.error.is_none()); + for i in 1..contents.len() { + let stray = renderer.render(request(i, RequestKind::Pixels)); + assert!(stray.error.is_none()); + assert_eq!((stray.width, stray.height), (balanced.width, balanced.height)); + assert!(stray.rgba == balanced.rgba, "page {} draws differently from the balanced form", i + 1); + } +} + +#[test] +fn a_balanced_q_inside_a_form_still_restores() { + // The balanced form's pen move stays inside its own `q … Q`, so SECRET sits where the page + // put it: 72 + 20 from the page's `cm`, and to the right of AA. + let pages = texts(&[page("B", false)]); + let secret = boxes(&pages[0], "SECRET"); + assert!((secret[0][0] - 92.0).abs() < 0.5, "SECRET starts at {}", secret[0][0]); +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..f2739a12e1 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (68) a `Q` with no `q` of its own content stream to match is ignored (`Context::stream_floor_reached`; a stream balances its own `q` and `Q`, ISO 32000-2 §8.4.2, §8.10.1): a form or appearance stream with two or more stray `Q`s popped the states its caller had saved, so the page's later text, paint and clip lost the caller's `cm` and clip, and search redaction marked the wrong place | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render), `it::stray_q_in_a_form::{a_forms_stray_q_does_not_undo_the_pages_cm, a_forms_stray_q_inside_a_text_object_does_not_undo_the_pages_cm, a_forms_stray_q_does_not_undo_the_pages_clip, a_balanced_q_inside_a_form_still_restores}` (pdfcraft-render), `it::redact_after_stray_q_form::find_and_redact_removes_text_drawn_after_a_form_with_stray_q` (pdfcraft-automation) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index ff178b25b9..d41f35ee83 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -173,6 +173,10 @@ pub struct Context<'a> { /// a limit was reached, the clip count to return to at its `Q`. saved_bytes: usize, skipped_saves: Vec, + /// PdfCraft patch (68): the `num_states` at which the nested content stream (a form, an + /// appearance, a glyph or pattern cell) being interpreted started, or 0 in page content. A + /// `Q` there has no `q` of its own stream to match (see `stream_floor_reached`). + stream_floor: usize, // PdfCraft patch (63). /// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`, /// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3, @@ -256,6 +260,7 @@ impl<'a> Context<'a> { nesting_depth, saved_bytes: 0, skipped_saves: Vec::new(), + stream_floor: 0, luminosity_in_device_cmyk: false, } } @@ -456,6 +461,25 @@ impl<'a> Context<'a> { self.get().graphics_state.stroke_props.clone() } + /// PdfCraft patch (68): starts a nested content stream at the current depth and returns the + /// floor of the stream it runs inside, for `leave_stream`. + pub(crate) fn enter_stream(&mut self) -> usize { + let depth = self.num_states(); + std::mem::replace(&mut self.stream_floor, depth) + } + + /// PdfCraft patch (68): ends a content stream begun with `enter_stream`. + pub(crate) fn leave_stream(&mut self, outer_floor: usize) { + self.stream_floor = outer_floor; + } + + /// PdfCraft patch (68): every `q` of the current content stream has been matched, so a `Q` + /// now would restore a state its caller saved (a stream balances its own `q` and `Q`, + /// ISO 32000-2 §8.4.2, §8.10.1). + pub(crate) fn stream_floor_reached(&self) -> bool { + self.num_states() <= self.stream_floor + } + pub(crate) fn num_states(&self) -> usize { // PdfCraft patch: saves not kept still count, so callers restore back past them. self.states.len().saturating_add(self.skipped_saves.len()) diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index cf71eb92b5..58213439ac 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -460,6 +460,12 @@ fn interpret_window<'a>( } match op { TypedInstruction::SaveState(_) => context.save_state(), + // PdfCraft patch (68): in a form or an appearance stream, a `Q` with no `q` of that + // stream to match is ignored, so it cannot pop the states of the content that drew + // it. Page content keeps its old behaviour (`stream_floor` stays 0 there). + TypedInstruction::RestoreState(_) if context.stream_floor_reached() => { + warn!("ignored a `Q` with no matching `q` in its content stream"); + } TypedInstruction::StrokeColorDeviceRgb(s) => { // PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it. context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Rgb); @@ -1013,12 +1019,14 @@ pub fn interpret<'a>( let num_states = context.num_states(); context.save_state(); + let outer_floor = context.enter_stream(); interpret_window(&mut ops, resources, context, device); while context.num_states() > num_states { context.restore_state(device); } + context.leave_stream(outer_floor); // PdfCraft patch (18): say that content was skipped, so a truncated page isn't a clean render. if crate::context::content_was_truncated() { (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); From ccb2565036dd9845e53af30cd665953976c61f9c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 16:16:07 -0400 Subject: [PATCH 018/120] fix(forms): auto-sized single-line text fields fill their height, not 12 pt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An auto-sized (`0 Tf`) single-line text field drew at most 12 pt however tall the box. Acrobat Reader draws it at the size the height gives (checked black-box on a synthetic form: 30 pt and 60 pt fields); ISO 32000-2 §12.7.4.3 leaves the size implementation-defined. auto_text_size_by now keeps the existing height / 1.15 size without the 12 pt cap, still shrinks to the width and never goes under 4 pt, with the height bounded at 14,400 (MAX_AUTO_HEIGHT, the largest page side ISO 32000-1 Annex C recommends). Multiline and comb auto-size keep their 12 pt cap: not yet checked against Acrobat. Tests: the forms unit tests now pin a tall box's size (they pinned 12 pt), and an automation test fills a 30 pt field through form_fill and measures the drawn text with text_find. All three fail with the cap restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 23 +++++++++++++++++++++++ crates/forms/src/appearance.rs | 14 ++++++++++---- crates/forms/src/tests.rs | 14 ++++++++++---- parity/acrobat-features.toml | 4 ++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..aa472ae975 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2548,6 +2548,29 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat +/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text. +#[test] +fn auto_sized_text_follows_a_tall_field() { + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3 + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6 + ]; + let dir = workdir("auto-size-tall"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } })); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" })); + let r = &found["matches"][0]["rects"][0]; + let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap(); + assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})"); +} + #[test] fn reversing_pages_through_tools() { let dir = workdir("reverse"); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c1bb5630a6..694cca9bec 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String { } /// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box -/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt. -/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose -/// wrapped lines fit the height. The page appearance and the on-screen editor share this. +/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text +/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is +/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline +/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page +/// appearance and the on-screen editor share this. pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 { let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 }; auto_text_size_by( @@ -54,6 +56,10 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side +/// ISO 32000-1 Annex C recommends. +pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; + /// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on /// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its /// own font, whose widths and line breaks differ from the page's. @@ -67,7 +73,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i } return size; } - let size = (height / 1.15).clamp(4.0, 12.0); + let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0); let tw = line_width(size); if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..59321d02d4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1796,7 +1796,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's - // Auto size: 12 pt where there is room, smaller where there isn't. + // Auto size: the size the box's height gives, smaller where the text is too wide. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") @@ -1815,7 +1815,9 @@ fn new_text_fields_fit_their_box() { } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); - assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15. + let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])); + assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); @@ -3158,8 +3160,12 @@ fn auto_text_size_fits_single_and_multiline_text() { assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); - // Never past 12 pt or under 4 pt. - assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); + // A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there), + // bounded by the largest page; never under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15); + assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15); + let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width); + assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}"); assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); // Multiline: the largest size whose wrapped lines fit the height. let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..490f411180 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4547,8 +4547,8 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] -notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" [[feature]] id = "form.rich-text-fields" From 0e559fca41dbefaf8347766eacf144686a6924d3 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 19:27:44 -0400 Subject: [PATCH 019/120] fix(ui-egui): the field editor sizes auto-size text as the page does Review on #4862 found two mismatches between the on-page field editor and the field's appearance: - Comb fields: the editor sized them as single-line text (20.87 pt for a 500 x 30 comb) while the appearance uses 4-12 pt from the cell height. The comb test is now `Field::is_comb` and the comb size `appearance::comb_auto_text_size`, both shared by the appearance and `forms_ui::field_text_size`. The editor's test was looser (it ignored Multiline and Password); it now matches the page's. - Tall fields: `field_text_size` capped at 300 pt and the three call sites clamped the screen font to 64. Both are gone; `field_screen_font` scales by zoom and bounds the result at MAX_SCREEN_FONT (256 screen points) so a glyph stays well inside the font atlas. A non-finite comb height now gives 4 pt, as single-line auto-size does. Test: it::forms::the_field_editor_sizes_auto_size_text_as_the_page_does. With the comb change reverted the comb assertion fails (20.87 vs 12); with the 300/64 caps restored the 100 pt assertion fails. Also fixes it::forms::dragging_over_another_fields_text_focuses_it_and_selects_from_the_press, which the previous commit broke: the name field now auto-sizes to 13.9 pt instead of 12, so the press point 30 px in fell on "A|da"; it is now 38 px in, inside "Ad|a" as the test intends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/engine/src/reexports.rs | 2 +- crates/forms/src/appearance.rs | 14 ++++++--- crates/forms/src/lib.rs | 6 ++++ crates/ui-egui/src/forms_ui.rs | 30 ++++++++++++------ crates/ui-egui/tests/it/forms.rs | 52 ++++++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs index 208a8e450b..2f4313eee8 100644 --- a/crates/engine/src/reexports.rs +++ b/crates/engine/src/reexports.rs @@ -20,7 +20,7 @@ pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, + appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags, }; pub use pdfcraft_a11y as a11y; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 694cca9bec..b6fedbd55c 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -56,6 +56,13 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside +/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance +/// and the on-screen editor share this. +pub fn comb_auto_text_size(height: f64) -> f64 { + if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 } +} + /// The tallest box single-line auto-size follows, as a sanity bound: the largest page side /// ISO 32000-1 Annex C recommends. pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; @@ -115,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), multiline, password: f.has(flags::PASSWORD), - comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + comb: f.is_comb(), } } @@ -819,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } _ => { - let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + let comb = f.is_comb(); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); @@ -846,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { if size == 0.0 { - // Comb cells hold one character each, so only the height limits them. size = if comb { - ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0) + comb_auto_text_size(height - 2.0 * pad) } else { auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 39cebd8fdd..1ac75adb78 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -155,6 +155,12 @@ impl Field { self.flags & flag != 0 } + /// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO + /// 32000-2 Table 228). Each cell holds one character. + pub fn is_comb(&self) -> bool { + self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0) + } + pub fn read_only(&self) -> bool { self.has(flags::READ_ONLY) } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 28494160c5..4138f4e903 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -331,7 +331,7 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: .show(ctx, |ui| { ui.set_min_size(rect.size()); let Some(fx) = view.forms.focus.as_mut() else { return }; - let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // The box is pale yellow in every theme, as a text field's is (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); let te = egui::TextEdit::singleline(&mut fx.text) @@ -523,17 +523,29 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t // The appearance's padding: 2 pt inside a 1 pt border. let pad = 3.0; let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad); - // Comb cells hold one character each, so only the height limits them. - let comb = f.has(field_flags::COMB) && f.max_len.is_some_and(|m| m > 0); - let fit = if comb { "" } else { text }; + // Comb cells hold one character each, so only the height limits them, as on the page. + if f.is_comb() { + return pdfcraft_engine::field_comb_auto_text_size(height); + } let font = |size: f64| egui::FontId::proportional(size as f32); - let line_width = |size: f64| f64::from(painter.layout_no_wrap(fit.to_string(), font(size), Color32::BLACK).size().x); + let line_width = |size: f64| f64::from(painter.layout_no_wrap(text.to_string(), font(size), Color32::BLACK).size().x); // Wrapped as the editor and the page preview wrap it. let wrap = width.max(1.0) as f32; - let wrapped_height = |size: f64| f64::from(painter.layout(fit.to_string(), font(size), Color32::BLACK, wrap).size().y); + let wrapped_height = |size: f64| f64::from(painter.layout(text.to_string(), font(size), Color32::BLACK, wrap).size().y); pdfcraft_engine::field_auto_text_size_by(width, height, f.has(field_flags::MULTILINE), line_width, wrapped_height) }); - size.min(300.0) as f32 + size as f32 +} + +/// The largest font, in screen points, the on-page field editor draws with: a bound that keeps +/// one rasterized glyph (font size times pixels per point) well inside egui's font atlas. +pub const MAX_SCREEN_FONT: f32 = 256.0; + +/// The font, in screen points, a field's editor shows `text` at, `zoom` screen points per page +/// point: [`field_text_size`] scaled, within 3 and [`MAX_SCREEN_FONT`]. +pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, text: &str, zoom: f32) -> f32 { + let font = field_text_size(painter, f, w, text) * zoom; + if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) } } fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { @@ -545,7 +557,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, if !text.is_empty() { let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw }; let zoom = xf.rect.width() / unrotated_w.max(1.0); - let font_size = (field_text_size(painter, f, w, text) * zoom).clamp(3.0, 64.0); + let font_size = field_screen_font(painter, f, w, text, zoom); let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() }; let p = painter.with_clip_rect(r); let (align, pos) = match f.quadding { @@ -683,7 +695,7 @@ pub(crate) fn overlay( ui.set_min_size(rect.size()); let Some(fx) = view.forms.focus.as_mut() else { return }; // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. - let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index dcaa7510b5..fa828a8f9f 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -406,8 +406,8 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) }; - // From inside the first word to past the end of the text. - let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); + // From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text. + let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); h.hover_at(from); h.run_steps(1); h.drag_at(from); @@ -1363,6 +1363,54 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } +/// Auto-size fields 500 pt wide: a four-cell comb 30 pt tall, and single-line fields 100 and 400 pt tall. +fn tall_auto_size_form() -> Vec { + pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (comb) /Ff 16777216 /MaxLen 4 /Rect [50 740 550 770] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [50 600 550 700] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (huge) /Rect [50 100 550 500] /P 3 0 R /F 4 >>", + ]) +} + +/// The editor shows auto-size text at the size the page appearance draws it: a comb field keeps +/// the appearance's 12 pt cap, and a tall single-line field follows its height, at 100% zoom +/// too, up to the editor's screen-font bound. +#[test] +fn the_field_editor_sizes_auto_size_text_as_the_page_does() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("tall.pdf", None, tall_auto_size_form()).unwrap(); + app + }); + h.run_steps(6); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let field = |name: &str| doc.form.iter().find(|f| f.name == name).unwrap().clone(); + let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING); + let screen = |name: &str, zoom: f32| { + let f = field(name); + pdfcraft_ui_egui::forms_ui::field_screen_font(&painter, &f, &f.widgets[0], "Hi", zoom) + }; + // 100%: 96 screen points per 72 page points. + let full = 96.0 / 72.0; + + let comb = field("comb"); + let page = pdfcraft_engine::field_comb_auto_text_size(30.0 - 6.0); + assert_eq!(page, 12.0); + assert_eq!(pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &comb, &comb.widgets[0], ""), 12.0, "a comb field"); + assert_eq!(screen("comb", full), 12.0 * full); + + let tall = (100.0 - 6.0) / 1.15; + assert!((screen("tall", full) - tall as f32 * full).abs() < 0.01, "a 100 pt field at 100%: {}", screen("tall", full)); + assert_eq!(screen("huge", full), pdfcraft_ui_egui::forms_ui::MAX_SCREEN_FONT, "past the screen-font bound"); + assert_eq!(screen("huge", 0.5), ((400.0 - 6.0) / 1.15) as f32 * 0.5, "below it at 50%"); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { use egui_kittest::kittest::Queryable; From 0457aa88f956d13eaeca46fe84583b76909f4649 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 19:51:58 -0400 Subject: [PATCH 020/120] fix(redact): Q inside a text object restores the text matrices; revert the render change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #4831 pointed out the PDF Association's erratum to ISO 32000-2 (pdf-association/pdf-issues #368): inside a text object, q/Q also save and restore Tm and Tlm (§9.4.1, referenced from §8.4.2). Acrobat 26.2 agrees on a synthetic PDF 2.0 page. The renderer on main already did this, so the previous commit's hayro-interpret patch (67) was wrong: it is reverted here, with its vendor/README row and the render tests. The real disagreement was in the redactor's own interpreter, which kept Tm and Tlm across Q. Find took its boxes from the renderer's text, the redactor measured the glyphs elsewhere, and redact_apply refused on residue. Its state stack now holds (Gs, Tm, Tlm) and Q restores all three. Test: it::redact_text_after_q, end to end on %PDF-2.0 fixtures, three cases (font change inside q/Q; q 100 0 Td Q; the same followed by T*). With the redactor change reverted, each case fails on its own at redact_apply. With only Tm restored, the T* case fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- .../tests/it/redact_text_after_q.rs | 57 ++++---- crates/redact/src/interp.rs | 11 +- crates/render/tests/it/main.rs | 1 - crates/render/tests/it/text_matrix_q.rs | 129 ------------------ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 15 +- 6 files changed, 43 insertions(+), 172 deletions(-) delete mode 100644 crates/render/tests/it/text_matrix_q.rs diff --git a/crates/automation/tests/it/redact_text_after_q.rs b/crates/automation/tests/it/redact_text_after_q.rs index 5a30354e1c..fceaffb872 100644 --- a/crates/automation/tests/it/redact_text_after_q.rs +++ b/crates/automation/tests/it/redact_text_after_q.rs @@ -1,14 +1,23 @@ -//! Search-and-redact of text that follows a `q`/`Q` inside a text object. Find's boxes come from -//! the renderer's text and redaction measures the content itself, so the two must agree on where -//! the text after `Q` is (vendored hayro-interpret patch 67); when they disagreed, `redact_apply` -//! refused to change anything. - +//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text +//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so +//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the +//! renderer's text, which places it there; redaction measures the content itself, and while it +//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything. use pdfcraft_automation::{Automation, Content}; use serde_json::{Value, json}; -const CONTENT: &str = "BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET"; +/// The content, the text that stays after SECRET is redacted, and the text that goes with it. +const CASES: [(&str, &str, &str); 3] = [ + // A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over + // the B and the B goes with it. + ("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"), + // A move inside `q`/`Q` is undone. + ("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"), + // The text line matrix is restored too: `T*` starts the next line from the line before `q`. + ("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"), +]; -fn pdf() -> Vec { +fn pdf(content: &str) -> Vec { let objects = [ "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), @@ -16,9 +25,9 @@ fn pdf() -> Vec { /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \ /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>" .to_owned(), - format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), ]; - let mut out = b"%PDF-1.7\n".to_vec(); + let mut out = b"%PDF-2.0\n".to_vec(); let mut offsets = Vec::new(); for (i, object) in objects.iter().enumerate() { offsets.push(out.len()); @@ -49,18 +58,20 @@ fn text(a: &mut Automation, doc: &Value) -> String { #[test] fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() { - let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("in.pdf"), pdf()).unwrap(); - let mut a = Automation::new().with_root(&dir).unwrap(); - let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); - assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1); - ok(&mut a, "redact_apply", json!({ "doc": doc })); - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); - let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); - let after = text(&mut a, &saved); - assert!(!after.contains("SECRET"), "{after:?}"); - assert!(after.contains("AAAAB"), "the text before the mark stays: {after:?}"); - let _ = std::fs::remove_dir_all(&dir); + for (case, (content, kept, gone)) in CASES.iter().enumerate() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}"); + assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index bd9da5215f..17ca06873b 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -801,7 +801,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { let mut out = Output::default(); let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut stack: Vec<(Gs, Matrix, Matrix)> = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); let ext_gstates = res_dict(doc, resources, b"ExtGState"); @@ -901,10 +904,10 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], clip = false; } match o { - b"q" => stack.push(gs.clone()), + b"q" => stack.push((gs.clone(), tm, tlm)), b"Q" => { - if let Some(g) = stack.pop() { - gs = g; + if let Some((g, m, lm)) = stack.pop() { + (gs, tm, tlm) = (g, m, lm); } } b"cm" => { diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index d376ca7e44..7c396b6f50 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -8,6 +8,5 @@ mod named_form_properties; mod search; mod separation_all; mod substitute_widths; -mod text_matrix_q; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/text_matrix_q.rs b/crates/render/tests/it/text_matrix_q.rs deleted file mode 100644 index 5a1303a17a..0000000000 --- a/crates/render/tests/it/text_matrix_q.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Contributor-original synthetic pages: `q`/`Q` inside a text object. The text matrices are not -//! part of the graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` restores the font -//! but not the pen (vendored hayro-interpret patch 67). Each case is paired with a page that -//! changes the font back by hand instead, which must come out the same. -use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; -use std::sync::Arc; - -/// `q`/`Q` around a larger font: the text after `Q` continues from the end of the "B". -const SAVED: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET"; -/// The same text with the font set back by `Tf`. -const BY_HAND: &str = "BT /F1 12 Tf 72 300 Td (AAAA) Tj /F2 24 Tf (B) Tj /F1 12 Tf (SECRET) Tj ET"; -/// A move inside `q`/`Q` moves the pen for good: `Td` is not undone by `Q`. -const MOVED_IN_SAVE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET"; -const MOVED: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj 100 0 Td (SECRET) Tj ET"; -/// A form that moves its own pen and ends with an unbalanced `Q`, shown inside a text object -/// (`Do` is not allowed there, but readers draw it). -const FORM_INSIDE: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj /X Do (SECRET) Tj ET"; -const FORM_BODY: &[u8] = b"BT /F1 12 Tf 200 0 Td ET Q"; -const NO_FORM: &str = "BT /F1 12 Tf 72 300 Td (AA) Tj (SECRET) Tj ET"; - -/// One page per entry of `contents`, 400 × 400, with Helvetica as /F1, Courier as /F2 and the -/// form /X. -fn pdf(contents: &[&str]) -> Arc> { - let n = contents.len(); - let kids: String = (0..n).map(|i| format!("{} 0 R ", 5 + 2 * i)).collect(); - let mut objects: Vec> = vec![ - b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), - format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(), - b"<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> /XObject << /X 4 0 R >> >>".to_vec(), - stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", FORM_BODY), - ]; - for (i, content) in contents.iter().enumerate() { - objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", 6 + 2 * i).into_bytes()); - objects.push(stream(b"", content.as_bytes())); - } - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, object) in objects.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(object); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for offset in offsets { - out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - Arc::new(out) -} - -fn stream(dict: &[u8], body: &[u8]) -> Vec { - let mut s = b"<< ".to_vec(); - s.extend_from_slice(dict); - s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes()); - s.extend_from_slice(body); - s.extend_from_slice(b"\nendstream"); - s -} - -fn request(page: usize, kind: RequestKind) -> RenderRequest { - RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false } -} - -fn texts(contents: &[&str]) -> Vec> { - let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default()); - (0..contents.len()) - .map(|page| { - let out = renderer.render(request(page, RequestKind::Text)); - assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error); - out.text.expect("page text") - }) - .collect() -} - -/// The boxes of the glyphs of `word`'s one match on the page. -fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> { - let hits = text.find(word); - assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text()); - hits[0].clone().map(|i| text.glyphs[i].rect).collect() -} - -fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) { - assert_eq!(a.len(), b.len()); - for (x, y) in a.iter().zip(b) { - assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}"); - } -} - -#[test] -fn text_after_q_q_continues_where_the_text_inside_left_off() { - let pages = texts(&[SAVED, BY_HAND]); - let secret = boxes(&pages[0], "SECRET"); - assert_same_boxes(&secret, &boxes(&pages[1], "SECRET")); - let b = boxes(&pages[0], "B"); - assert!(secret[0][0] >= b[0][2] - 0.01, "SECRET starts at {} inside the B ending at {}", secret[0][0], b[0][2]); -} - -#[test] -fn q_q_still_restores_the_font() { - let pages = texts(&[SAVED, BY_HAND]); - let height = |r: [f32; 4]| r[3] - r[1]; - let secret = boxes(&pages[0], "SECRET"); - let b = boxes(&pages[0], "B"); - assert!(height(secret[0]) < height(b[0]) * 0.75, "SECRET is drawn at the 12 pt font, not the B's 24 pt"); -} - -#[test] -fn a_move_inside_q_q_is_kept() { - let pages = texts(&[MOVED_IN_SAVE, MOVED]); - assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET")); -} - -#[test] -fn a_forms_unbalanced_q_does_not_move_the_page_text() { - let pages = texts(&[FORM_INSIDE, NO_FORM]); - assert_same_boxes(&boxes(&pages[0], "SECRET"), &boxes(&pages[1], "SECRET")); -} - -#[test] -fn q_q_inside_a_text_object_draws_like_the_font_set_back_by_hand() { - let mut renderer = PageRenderer::new(pdf(&[SAVED, BY_HAND]), RenderConfig::default()); - let saved = renderer.render(request(0, RequestKind::Pixels)); - let by_hand = renderer.render(request(1, RequestKind::Pixels)); - assert!(saved.error.is_none() && by_hand.error.is_none()); - assert_eq!((saved.width, saved.height), (by_hand.width, by_hand.height)); - assert!(saved.rgba == by_hand.rgba, "the two pages draw differently"); -} diff --git a/vendor/README.md b/vendor/README.md index 59b2d98b42..20fb0c75a8 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (67) `Q` inside a text object no longer restores the text and text line matrices (ISO 32000-2 §8.4.1 Table 51, §9.4.1: they are not graphics state): text after `q … Q` was drawn, extracted and found back at the position from before `q`, so search redaction marked the wrong place and refused to apply | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render), `it::text_matrix_q::{text_after_q_q_continues_where_the_text_inside_left_off, a_move_inside_q_q_is_kept, q_q_inside_a_text_object_draws_like_the_font_set_back_by_hand, q_q_still_restores_the_font, a_forms_unbalanced_q_does_not_move_the_page_text}` (pdfcraft-render), `it::redact_text_after_q::find_and_redact_removes_text_after_a_q_q_inside_its_text_object` (pdfcraft-automation) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index a890616dc0..cf71eb92b5 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -637,20 +637,7 @@ fn interpret_window<'a>( TypedInstruction::ClipEvenOdd(_) => { *(context.clip_mut()) = Some(FillRule::EvenOdd); } - TypedInstruction::RestoreState(_) => { - // PdfCraft patch (67): the text and text line matrices are not part of the - // graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1), so a `Q` inside a text - // object keeps the pen where the text before it left it. Outside one, the next - // `BT` resets both. Forms and annotations run between a save and restore of their - // own, and Type 3 glyphs, patterns and soft masks in a context of their own, so a - // stream's pen does not reach its caller unless the stream pops past its own - // saves, which already hands the caller a wrong state. - let text = &context.get().text_state; - let pen = (text.text_matrix, text.text_line_matrix); - context.restore_state(device); - let text = &mut context.get_mut().text_state; - (text.text_matrix, text.text_line_matrix) = pen; - } + TypedInstruction::RestoreState(_) => context.restore_state(device), TypedInstruction::FlatnessTolerance(_) => { // Ignore for now. } From ad33ab626e7381792e1040a6df920c9b29ae1e5d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 20:09:27 -0400 Subject: [PATCH 021/120] fix(render): a non-isolated knockout group blends its elements with the page under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-isolated knockout group starts from the backdrop under it (ISO 32000-2 §11.4.7, §11.4.8), but was drawn into a layer of its own starting transparent, so a blend mode inside it had nothing to blend with: white with /BM /Difference over magenta stayed white (#4886). Entered plainly (full opacity, no soft mask, Normal, not itself a knockout element), the group is now drawn once per blend mode its elements use. Each pass is a knockout layer composited onto the backdrop in that mode; elements of other modes clear what is under their shape instead of painting. The Normal pass finds the other modes, so a group without blend modes takes one pass as before. hayro-interpret patch 67 (Device::begin_knockout_pass / end_knockout_pass, draw_knockout_passes), hayro patch 26 (the renderer side). Test: pdfcraft-render it::knockout_backdrop, three cases failing with the fix reverted, plus an isolated-group control. Fixes #4886 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/knockout_backdrop.rs | 133 ++++++++++++++++++++ crates/render/tests/it/main.rs | 1 + vendor/README.md | 4 +- vendor/hayro-interpret/src/device.rs | 13 ++ vendor/hayro-interpret/src/ocg.rs | 11 ++ vendor/hayro-interpret/src/x_object.rs | 80 +++++++++++- vendor/hayro/src/renderer.rs | 92 +++++++++++++- 7 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 crates/render/tests/it/knockout_backdrop.rs diff --git a/crates/render/tests/it/knockout_backdrop.rs b/crates/render/tests/it/knockout_backdrop.rs new file mode 100644 index 0000000000..126de8a713 --- /dev/null +++ b/crates/render/tests/it/knockout_backdrop.rs @@ -0,0 +1,133 @@ +//! #4886: a non-isolated knockout group (`/I false /K true`) starts from the backdrop under it +//! (ISO 32000-2 §11.4.7, §11.4.8), so each element blends with the page there, in its own blend +//! mode, and replaces the elements before it under its shape. White with `/BM /Difference` over +//! magenta is green; drawn into a layer of its own starting transparent, it stayed white. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +const MAGENTA: [u8; 3] = [255, 0, 255]; +const GREEN: [u8; 3] = [0, 255, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, content: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// Renders a 200 x 200 page: magenta, then the group `/Fk` with group attributes `group` and +/// content `content`. Its resources have `/D` (`/BM /Difference`), `/N` (`/BM /Normal`), `/H` +/// (`/ca 0.5`), `/W`, a white square from 40 to 160 in a group of its own, `/Nest`, a group +/// holding a non-isolated knockout group with a white Difference square from 80 to 120, and +/// `/Img`, a white 1 x 1 image. +/// Returns a lookup of the colour at PDF point (x, y). +fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { + let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Fk 5 0 R >> >> >>" + .to_string(), + stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R >> >>"), + content, + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /K2 8 0 R >> >>", "/K2 Do"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I false /K true >> /Resources << {gs} >>"), + "/D gs 1 1 1 rg 80 80 40 40 re f", + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FFFFFF>"), + ]; + let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + let (rgba, width) = (page.rgba.to_vec(), page.width); + move |x, y| { + // Pixel rows run top down. + let offset = usize::try_from(((200 - y) * width + x) * 4).unwrap(); + [rgba[offset], rgba[offset + 1], rgba[offset + 2]] + } +} + +fn check(at: &impl Fn(u32, u32) -> [u8; 3], x: u32, y: u32, want: [u8; 3], what: &str) { + let got = at(x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what} at ({x}, {y}): {got:?}, expected {want:?}"); +} + +const NON_ISOLATED: &str = "/I false /K true"; +const SQUARES: &str = "/D gs 1 1 1 rg 20 20 110 110 re f 70 70 110 110 re f"; + +#[test] +fn a_non_isolated_knockout_group_blends_each_element_with_the_page() { + let squares = render(NON_ISOLATED, SQUARES); + check(&squares, 40, 40, GREEN, "first square"); + check(&squares, 160, 160, GREEN, "second square"); + // The second square blends with the page, not with the first: green, not magenta again. + check(&squares, 100, 100, GREEN, "overlap"); + check(&squares, 190, 10, MAGENTA, "page outside the group's elements"); +} + +#[test] +fn the_last_element_over_a_point_decides_its_blend_mode() { + // Normal blue at 50 % after a Difference square: blue over the page where they overlap. + let blue_last = render(NON_ISOLATED, "/D gs 1 1 1 rg 20 20 110 110 re f /N gs 0 0 1 rg q /H gs 70 70 110 110 re f Q"); + check(&blue_last, 40, 40, GREEN, "Difference alone"); + check(&blue_last, 100, 100, [128, 0, 255], "Normal blue last"); + check(&blue_last, 160, 160, [128, 0, 255], "Normal blue alone"); + // The other way round: the Difference square replaces the blue and blends with the page. + let white_last = render(NON_ISOLATED, "q /H gs 0 0 1 rg 20 20 110 110 re f Q /D gs 1 1 1 rg 70 70 110 110 re f"); + check(&white_last, 40, 40, [128, 0, 255], "Normal blue alone"); + check(&white_last, 100, 100, GREEN, "Difference last"); +} + +#[test] +fn a_nested_group_in_a_non_isolated_knockout_group_blends_with_the_page() { + let nested = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /W Do"); + check(&nested, 100, 100, GREEN, "nested group drawn with Difference over the knocked-out blue"); + check(&nested, 30, 30, [0, 0, 255], "blue beside the nested group"); +} + +#[test] +fn an_isolated_knockout_group_still_starts_transparent() { + let isolated = render("/I true /K true", SQUARES); + check(&isolated, 40, 40, WHITE, "isolated: nothing to blend with"); + check(&isolated, 100, 100, WHITE, "isolated overlap"); +} + +#[test] +fn an_image_and_a_partly_transparent_element_blend_with_the_page() { + let image = render(NON_ISOLATED, "/D gs q 120 0 0 120 40 40 cm /Img Do Q"); + check(&image, 100, 100, GREEN, "white image drawn with Difference"); + check(&image, 20, 20, MAGENTA, "page beside the image"); + // Difference at 50 %: half the page, half the blend result (green). + let half = render(NON_ISOLATED, "/D gs /H gs 1 1 1 rg 40 40 120 120 re f"); + check(&half, 100, 100, [128, 128, 128], "Difference at 50 %"); +} + +/// A knockout group nested in another one's element is drawn first in that element's erase +/// pass, which must leave the outer group's passes alone (review of #4886's fix). +#[test] +fn a_knockout_group_inside_an_element_keeps_the_outer_groups_passes() { + let nested = render(NON_ISOLATED, "/D gs 1 1 1 rg 10 10 50 50 re f /N gs /Nest Do /D gs 1 1 1 rg 140 140 50 50 re f"); + check(&nested, 30, 30, GREEN, "Difference square before the nested groups"); + check(&nested, 165, 165, GREEN, "Difference square after the nested groups"); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..8a783a6803 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -3,6 +3,7 @@ mod cid_fallback_query; mod inline_image_ei; mod jbig2_invariants; +mod knockout_backdrop; mod luminosity_group_space; mod named_form_properties; mod search; diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..b1dcd406bb 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,8 +39,8 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (67) a non-isolated knockout group entered at full opacity, without a soft mask and with Normal blending is drawn once per blend mode its elements use (`Device::begin_knockout_pass`, `end_knockout_pass`), each pass a layer composited onto the backdrop in that mode, so each element blends with the group's backdrop in its own blend mode (ISO 32000-2 §11.4.7, §11.4.8; #4886): drawn as one layer starting transparent, white with `/BM /Difference` over magenta stayed white; each pass starts from the same marked-content visibility; inside an element's knockout erase pass a nested group takes one pass, leaving the outer group's passes alone; a masked image or stencil of another mode clears nothing under it, as in (39) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (67) `it::knockout_backdrop` (pdfcraft-render) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (26) the renderer side of hayro-interpret (67): in a blend-mode pass over a knockout group, an element of another blend mode clears what is under its shape instead of painting (a path, glyph or shading through `set_paint`; an image or nested group's layer composites at opacity 0 after its shape was cleared), and the pass reports the other modes its elements used | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (26) `it::knockout_backdrop` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index f85969dff3..8c86db7e6e 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -107,6 +107,19 @@ pub trait Device<'a> { fn begin_knockout_erase(&mut self) {} /// PdfCraft patch (39): see `knockout_erase_wanted`. fn end_knockout_erase(&mut self) {} + /// PdfCraft patch (67): the knockout group just pushed (`set_knockout`) is drawn once per + /// blend mode its elements use, each pass into a layer of its own composited with that + /// mode. In this pass only elements whose blend mode is `mode` paint; every other element + /// clears what is under its shape, as it would replace it. Each pixel then holds the last + /// element covering it in exactly one pass, which composites it with the group's backdrop + /// in that element's own blend mode (#4886). + fn begin_knockout_pass(&mut self, _mode: BlendMode) {} + /// PdfCraft patch (67): ends the pass `begin_knockout_pass` started, before the layer is + /// popped. Returns the other blend modes the pass's elements used, each once. A device + /// that doesn't track them returns none, and the group is drawn in one Normal pass. + fn end_knockout_pass(&mut self) -> Vec { + Vec::new() + } } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index ac3e57529b..c65a195209 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -11,6 +11,17 @@ pub(crate) struct OcgState { } impl OcgState { + /// PdfCraft patch (67): the marked-content visibility now, to put back after content that + /// may leave it unbalanced is interpreted once more (`draw_knockout_passes`). + pub(crate) fn visibility(&self) -> Vec { + self.visibility_stack.clone() + } + + /// PdfCraft patch (67): see `visibility`. + pub(crate) fn set_visibility(&mut self, visibility: Vec) { + self.visibility_stack = visibility; + } + fn dummy() -> Self { Self { inactive_ocgs: HashSet::default(), diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 304b14a085..83de5d3976 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -155,13 +155,14 @@ pub(crate) fn draw_form_xobject<'a, 'b>( // starts from a blank backdrop by definition, and a knockout group or one entered at partial // opacity, under a soft mask or with a blend mode keeps its layer, where the group's // elements still composite with a blank backdrop (an approximation of §11.4.7 for them). - let neutral = !x_object.is_isolated && !x_object.is_knockout && { + let entered_plainly = { let state = &context.get().graphics_state; state.non_stroke_alpha >= 1.0 && state.stroke_alpha >= 1.0 && state.soft_mask.is_none() && state.blend_mode == BlendMode::Normal }; + let neutral = !x_object.is_isolated && !x_object.is_knockout && entered_plainly; // PdfCraft patch (39): a group drawn as an element of a knockout group replaces what that // group drew under its shape, the union of its own elements' shapes (#2971). The shape is // cleared by drawing the group's content once in the device's erase mode; the group then @@ -193,6 +194,26 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.restore_state(device); context.path_mut().truncate(0); } + // PdfCraft patch (67): a non-isolated knockout group entered the same way composites each + // element with the backdrop already on the page, in the element's own blend mode (#4886). A + // layer of its own starts transparent, where a blend mode has nothing to blend with, so the + // group is drawn in passes, one per blend mode, each composited onto the page in its mode + // (`Device::begin_knockout_pass`). + if x_object.is_transparency_group + && x_object.is_knockout + && !x_object.is_isolated + && entered_plainly + && !knockout_element + { + draw_knockout_passes(x_object, resources, context, device); + context.pop_root_transform(); + context.restore_state(device); + if has_oc { + context.ocg_state.end_marked_content(); + } + context.end_nested_interpretation(); + return; + } let own_layer = x_object.is_transparency_group && (!neutral || knockout_element); if own_layer { @@ -248,6 +269,63 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.end_nested_interpretation(); } +/// PdfCraft patch (67): draws a non-isolated knockout group entered at full opacity, without a +/// soft mask and with Normal blending, once per blend mode its elements use. The Normal pass +/// finds the other modes; there are at most as many passes as blend modes. +fn draw_knockout_passes<'a>( + x_object: &FormXObject<'a>, + resources: &Resources<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { + let clip = ClipPath { + path: context.get().ctm + * Rect::new( + x_object.bbox[0] as f64, + x_object.bbox[1] as f64, + x_object.bbox[2] as f64, + x_object.bbox[3] as f64, + ) + .to_path(0.1), + fill: FillRule::NonZero, + }; + // Each pass starts from the same marked-content state, also after a stream that leaves a + // `BDC` open. + let visibility = context.ocg_state.visibility(); + let mut modes = vec![BlendMode::Normal]; + let mut pass = 0; + while let Some(&mode) = modes.get(pass) { + context.ocg_state.set_visibility(visibility.clone()); + context.save_state(); + device.push_transparency_group(1.0, None, mode); + device.set_knockout(); + device.begin_knockout_pass(mode); + device.set_soft_mask(None); + device.set_blend_mode(BlendMode::Normal); + device.push_clip_path(&clip); + interpret( + TypedIter::new(x_object.decoded.as_ref()), + &Resources::for_form(x_object.resources.clone(), resources.clone()), + context, + device, + ); + device.pop_clip_path(); + let others = device.end_knockout_pass(); + device.pop_transparency_group(); + context.restore_state(device); + context.path_mut().truncate(0); + if pass == 0 { + for other in others { + if !modes.contains(&other) { + modes.push(other); + } + } + } + pass += 1; + } + context.ocg_state.set_visibility(visibility); +} + pub(crate) fn draw_image_xobject<'a, 'b>( x_object: &ImageXObject<'b>, context: &mut Context<'a>, diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 242c54ec2b..2524479eb9 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -451,6 +451,9 @@ pub(crate) struct Renderer { /// how many transparency groups were skipped in it (nothing is layered while erasing). erasing: bool, erase_groups: usize, + /// PdfCraft patch (26): the knockout groups drawn in blend-mode passes + /// (`Device::begin_knockout_pass`), innermost last. + knockout_passes: Vec, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -479,6 +482,32 @@ impl Renderer { self.knockout.last().copied().unwrap_or(false) } + /// PdfCraft patch (26): the blend-mode pass of the innermost group, when that group is a + /// knockout group drawn in passes, so the element now drawn is one of its own elements. + fn knockout_pass(&mut self) -> Option<&mut KnockoutPass> { + let depth = self.knockout.len(); + if !self.in_knockout() { + return None; + } + self.knockout_passes.last_mut().filter(|pass| pass.depth == depth) + } + + /// PdfCraft patch (26): whether an element of blend mode `mode` belongs to another pass than + /// the current one, so here it clears what is under its shape instead of painting. Records + /// the mode for its own pass. + fn knockout_pass_skips(&mut self, mode: BlendMode) -> bool { + let Some(pass) = self.knockout_pass() else { + return false; + }; + if mode == pass.mode { + return false; + } + if !pass.other_modes.contains(&mode) { + pass.other_modes.push(mode); + } + true + } + pub(crate) fn new( width: u16, height: u16, @@ -493,6 +522,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -597,6 +627,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -1103,7 +1134,10 @@ impl Renderer { fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { // PdfCraft patch (22): in a knockout erase pass every paint clears what is under its // shape (a transparent fill with `Compose::Copy`, weighted by coverage). - if self.erasing { + let element_mode = self.knockout_pass().map(|pass| pass.element_mode); + let other_pass = + !self.erasing && element_mode.is_some_and(|mode| self.knockout_pass_skips(mode)); + if self.erasing || other_pass { self.ctx.set_blend_mode(knockout_blend_mode()); self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); // A shading's `/BBox` clips what it paints, so it clips what it clears too. @@ -1265,6 +1299,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, cur_mask: None, inside_pattern: true, @@ -1327,6 +1362,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, cur_mask: None, inside_pattern: true, @@ -1625,6 +1661,7 @@ impl<'a> Device<'a> for Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -1708,6 +1745,13 @@ impl<'a> Device<'a> for Renderer { self.erase_groups += 1; return; } + // PdfCraft patch (26): an image or group of another pass than the current one composites + // nothing; what it covers was cleared before it (`knockout_image`, the erase pass). + let opacity = if self.knockout_pass_skips(blend_mode) { + 0.0 + } else { + opacity + }; let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() || blend_mode != BlendMode::Normal @@ -1786,6 +1830,32 @@ impl<'a> Device<'a> for Renderer { self.ctx.set_blend_mode(knockout_blend_mode()); } + fn begin_knockout_pass(&mut self, mode: BlendMode) { + if self.in_knockout() && !self.erasing { + self.knockout_passes.push(KnockoutPass { + depth: self.knockout.len(), + mode, + element_mode: BlendMode::Normal, + other_modes: Vec::new(), + }); + } + } + + fn end_knockout_pass(&mut self) -> Vec { + // In an erase pass `begin_knockout_pass` started nothing; the pass at this depth belongs + // to the knockout group being erased into, and stays. + if self.erasing { + return Vec::new(); + } + let depth = self.knockout.len(); + match self.knockout_passes.last() { + Some(pass) if pass.depth == depth => { + self.knockout_passes.pop().map(|pass| pass.other_modes).unwrap_or_default() + } + _ => Vec::new(), + } + } + fn in_knockout_group(&mut self) -> bool { self.in_knockout() } @@ -1925,6 +1995,13 @@ impl<'a> Device<'a> for Renderer { } fn set_blend_mode(&mut self, blend_mode: BlendMode) { + // PdfCraft patch (26): the blend mode of the element about to paint, for its pass (an + // erase pass paints nothing, and its content's modes are not the element's). + if !self.erasing + && let Some(pass) = self.knockout_pass() + { + pass.element_mode = blend_mode; + } // PdfCraft patch (22): inside a knockout group every element replaces what it covers. let mode = if self.in_knockout() { knockout_blend_mode() @@ -2017,6 +2094,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, cur_mask: None, @@ -2112,6 +2190,18 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill { } } +/// PdfCraft patch (26): one blend-mode pass over a knockout group (`Device::begin_knockout_pass`). +struct KnockoutPass { + /// `knockout.len()` with the group pushed: its own elements are drawn at this depth. + depth: usize, + /// The blend mode whose elements paint in this pass. + mode: BlendMode, + /// The blend mode of the element painting now (`set_blend_mode`). + element_mode: BlendMode, + /// The other blend modes this pass's elements used, each once (at most one per mode). + other_modes: Vec, +} + /// PdfCraft patch (22): how an element of a knockout group is drawn (see `set_knockout`). fn knockout_blend_mode() -> peniko::BlendMode { peniko::BlendMode::new(Mix::Normal, Compose::Copy) From dffe6dd703794297f540a5b0319b35a6fce38e6d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 23:24:24 -0400 Subject: [PATCH 022/120] fix(edit, a11y): Q inside a text object restores the text matrices, as the renderer does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a text object q/Q save and restore Tm and Tlm too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's erratum, pdf-association/pdf-issues #368). The renderer's saved state already holds both. The text-editing interpreter and the heading detector kept them across Q: text after Q was listed (and, once edited, moved) where the q ... Q block left the pen, and a word continued after Q was split ("Chap ter One"). Both saved stacks now hold the two matrices. Tests: pdfcraft-edit and pdfcraft-a11y tests::q_inside_a_text_object_restores_the_text_matrices. Reverting the edit fix puts the first case at y = 600, not 700; restoring only Tm puts the T* case at 586, not 686; reverting the a11y fix reads "Chap ter One". Each fails its test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/a11y/src/headings.rs | 9 ++++++--- crates/a11y/src/tests.rs | 13 +++++++++++++ crates/edit/src/tests.rs | 21 +++++++++++++++++++++ crates/edit/src/text/interp.rs | 13 ++++++++----- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 41c749ed3c..0fa31ccbb1 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { // Where each id's last glyph ended, in user space. let mut ends: HashMap = HashMap::new(); let mut gs = TextState::default(); - let mut saved: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new(); let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { @@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } - b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)), b"q" => unsaved += 1, b"Q" if unsaved > 0 => unsaved -= 1, b"Q" => { if let Some(saved) = saved.pop() { - gs = saved; + (gs, tm, tlm) = saved; } } b"cm" => { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a6dcdba199..a70ffbf385 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() { ); } +/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as +/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose +/// glyphs continue after a `Q` stays one word. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(), + "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["Chapter One", "Chapter Two"]); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0f8adc6514..41e67524d0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7457,3 +7457,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } + +/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 +/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the +/// text after `Q` is found, and stays, where it is drawn. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + for (content, text, baseline) in [ + ("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]), + ("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]), + // `T*` moves from the restored line, not the one set inside q/Q. + ("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]), + ] { + let mut doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}"); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing"); + } +} diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index ca0d63cef4..3fa2d8e0dd 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) { /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. pub(super) struct Carry { pub(super) ts: Ts, - pub(super) stack: Vec, + /// Each saved state with the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + pub(super) stack: Vec<(Ts, Matrix, Matrix)>, pub(super) marked: Vec>, /// For each open marked-content sequence, its optional content group when it is `/OC`. pub(super) layers: Vec>>, @@ -487,7 +490,7 @@ pub(super) fn interpret( let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); - let mut stack: Vec = std::mem::take(&mut carry.stack); + let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; let mut marked = std::mem::take(&mut carry.marked); @@ -551,10 +554,10 @@ pub(super) fn interpret( } } } - b"q" => stack.push(ts.clone()), + b"q" => stack.push((ts.clone(), tm, tlm)), b"Q" => { - if let Some(s) = stack.pop() { - ts = s; + if let Some(saved) = stack.pop() { + (ts, tm, tlm) = saved; } } b"cm" => { From 3c6612a3f90fec47bde5f58dd61a63ba336b73b6 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 00:34:14 -0400 Subject: [PATCH 023/120] fix(forms-ui): measure auto-size text at no more than the screen-font bound An auto-size single-line field's size follows its height, up to about 12,500 pt for a 14,400 pt field. The editor measured the line's width by laying it out at that size, before `field_screen_font` clamps to `MAX_SCREEN_FONT`, and egui rasterizes every glyph it lays out: one such measurement took 1.2 s in a debug build, on every frame. The width is now measured at no more than `MAX_SCREEN_FONT` and scaled, which is exact up to the bound and linear above it. `tools::forms_ui::tests::a_line_is_never_laid_out_above_the_screen_font_bound` fails with the bound removed (laid out at 12,522 pt). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/tools/forms_ui.rs | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 4138f4e903..e5a1b36bc8 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -528,7 +528,9 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t return pdfcraft_engine::field_comb_auto_text_size(height); } let font = |size: f64| egui::FontId::proportional(size as f32); - let line_width = |size: f64| f64::from(painter.layout_no_wrap(text.to_string(), font(size), Color32::BLACK).size().x); + let line_width = |size: f64| { + bounded_line_width(size, |at| painter.layout_no_wrap(text.to_string(), egui::FontId::proportional(at), Color32::BLACK).size().x) + }; // Wrapped as the editor and the page preview wrap it. let wrap = width.max(1.0) as f32; let wrapped_height = |size: f64| f64::from(painter.layout(text.to_string(), font(size), Color32::BLACK, wrap).size().y); @@ -537,6 +539,14 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t size as f32 } +/// The width of a line at `size` points from `measure`, the width at a given size, called at no +/// more than [`MAX_SCREEN_FONT`] and scaled: egui rasterizes every glyph it lays out, and a tall +/// field's automatic size can be thousands of points. +fn bounded_line_width(size: f64, measure: impl Fn(f32) -> f32) -> f64 { + let at = size.min(f64::from(MAX_SCREEN_FONT)); + if at > 0.0 { f64::from(measure(at as f32)) * size / at } else { f64::from(measure(at as f32)) } +} + /// The largest font, in screen points, the on-page field editor draws with: a bound that keeps /// one rasterized glyph (font size times pixels per point) well inside egui's font atlas. pub const MAX_SCREEN_FONT: f32 = 256.0; @@ -960,3 +970,22 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: let xf = view.page_xform(page)?; Some(widget_rect(&xf, info, page, w.rect)) } + +#[cfg(test)] +mod tests { + use super::{MAX_SCREEN_FONT, bounded_line_width}; + use std::cell::Cell; + + #[test] + fn a_line_is_never_laid_out_above_the_screen_font_bound() { + let largest = Cell::new(0.0f32); + let measure = |at: f32| { + largest.set(largest.get().max(at)); + at * 3.0 + }; + assert_eq!(bounded_line_width(12_522.0, measure), 12_522.0 * 3.0, "the width scales"); + assert_eq!(largest.get(), MAX_SCREEN_FONT, "laid out at the bound"); + assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself"); + assert_eq!(largest.get(), MAX_SCREEN_FONT); + } +} From a7b07bfc565553577442b577416623b9401704e0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 00:34:47 -0400 Subject: [PATCH 024/120] fix(render): a stroke whose outline comes back with NaN is drawn, not dropped (#4907) kurbo 0.13.1's stroker can give a cubic that is nearly a line (all control points within a rounding error of its chord) a NaN join, and vello_common's `flatten::fill` drops any path containing NaN, so the whole outline vanished. An Illustrator outline ending in `73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c` did (#4907). kurbo tracks the bug class in linebender/kurbo#617 and #519; #610 is unreleased. Vendored vello_common patch (2): when the expanded stroke is not finite, `flatten::stroke` strokes the path again with every quadratic or cubic whose control points lie within the tolerance of its chord replaced by a line, one element out per element in. A finite outline is unchanged, and a path with non-finite input is still dropped. `stroke` and `StripGenerator::generate_stroked_path` take a `Clone` path; vello_cpu's callers pass `&BezPath` and a copied slice iterator. Tests: `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` pixel test fail with the fallback disabled; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` are guards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/main.rs | 1 + .../tests/it/stroke_degenerate_cubic.rs | 51 +++++++ vendor/README.md | 2 +- vendor/vello_common/src/flatten.rs | 128 +++++++++++++++++- vendor/vello_common/src/strip_generator.rs | 3 +- 5 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/it/stroke_degenerate_cubic.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..68f7767e5a 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -7,6 +7,7 @@ mod luminosity_group_space; mod named_form_properties; mod search; mod separation_all; +mod stroke_degenerate_cubic; mod substitute_widths; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/stroke_degenerate_cubic.rs b/crates/render/tests/it/stroke_degenerate_cubic.rs new file mode 100644 index 0000000000..ff0e2542f9 --- /dev/null +++ b/crates/render/tests/it/stroke_degenerate_cubic.rs @@ -0,0 +1,51 @@ +//! A contributor-original synthetic page reproducing #4907: an outline that ends in a cubic whose +//! control points all sit 0.001 pt from its start was not drawn at all, because the stroker put +//! NaN into its outline and the rasterizer dropped the whole path. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A one-page 100 × 20 pt PDF drawing `content`, with a cross-reference table. +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 20] /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×. +fn red_at(content: &str, x: usize, y: usize) -> u8 { + let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + *out.rgba.get(at).expect("the pixel lies on the page") +} + +#[test] +fn a_stroke_ending_in_a_tiny_degenerate_cubic_is_drawn() { + // The line runs from (10, 10) to about (84, 9.5) in page space; (50, 10) from the top left is + // inside its 2 pt width. The cubic's exact coordinates are what trigger the NaN. The control, + // without the cubic, is the baseline: it draws with or without the fix. + let path = "0 0 m 73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c S"; + let control = "0 0 m 73.763 -0.536 l S"; + for content in [control, path] { + let content = format!("0 G 2 w 1 0 0 1 10 10 cm {content}"); + let red = red_at(&content, 50, 10); + assert!(red < 64, "`{content}` left the line's pixel at red {red}, want black"); + } +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..e6cc0d2aa6 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,7 +46,7 @@ Other resource categories retain their existing parent-chain lookup. | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | -| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | +| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical. (2) when kurbo's stroker returns an outline containing NaN, which `fill` would drop whole, `flatten::stroke` strokes the path again with each curve whose control points lie within the tolerance of its chord as a line. A nearly straight cubic can get a NaN join (kurbo issues #617 and #519, open PR #610; still in kurbo 0.13.1), and an Illustrator outline ending in one disappeared (#4907). A finite outline is unchanged. `stroke` and `StripGenerator::generate_stroked_path` take a `Clone` path; vello_cpu passes `&BezPath` or a copied slice iterator | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate. (2) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` pixel test fail without the patch; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` guard the other cases and pass either way | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | | hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs index be219c6c32..afacf7ecbc 100644 --- a/vendor/vello_common/src/flatten.rs +++ b/vendor/vello_common/src/flatten.rs @@ -5,7 +5,7 @@ use crate::flatten_simd::{Callback, LinePathEl}; use crate::geometry::RectU16; -use crate::kurbo::{self, Affine, PathEl, Stroke, StrokeCtx, StrokeOpts}; +use crate::kurbo::{self, Affine, BezPath, PathEl, Stroke, StrokeCtx, StrokeOpts}; use alloc::vec::Vec; use fearless_simd::{Level, Simd, dispatch}; use log::warn; @@ -220,7 +220,8 @@ pub fn fill_impl( /// See the note about open subpaths and culling on [`fill`]. pub fn stroke( level: Level, - path: impl IntoIterator, + // PdfCraft patch: `Clone` so the path can be stroked a second time. + path: impl IntoIterator + Clone, style: &Stroke, affine: Affine, line_buf: &mut Vec, @@ -235,7 +236,14 @@ pub fn stroke( .max(affine.as_coeffs()[3].abs()) .max(1.); - expand_stroke(path, style, tolerance, stroke_ctx); + expand_stroke(path.clone(), style, tolerance, stroke_ctx); + // PdfCraft patch: kurbo's stroker can put NaN into the outline of a curve that is nearly a + // line (all control points within a rounding error of the chord), and `fill` then drops the + // whole outline. Stroke it again with such curves as lines. A finite outline is unchanged. + if !stroke_ctx.output().is_finite() { + let path = near_line_curves_as_lines(path, tolerance); + expand_stroke(&path, style, tolerance, stroke_ctx); + } fill( level, stroke_ctx.output(), @@ -246,6 +254,49 @@ pub fn stroke( ); } +// PdfCraft patch. +/// `path` with each quadratic or cubic whose control points all lie within +/// `tolerance` of its chord replaced by a line to its end point. One element out per element in. +fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: f64) -> BezPath { + let mut out = BezPath::new(); + let mut start = kurbo::Point::ZERO; + let mut last = start; + for el in path { + let el = match el { + PathEl::QuadTo(p1, p2) if near_segment(p1, last, p2, tolerance) => PathEl::LineTo(p2), + PathEl::CurveTo(p1, p2, p3) + if near_segment(p1, last, p3, tolerance) + && near_segment(p2, last, p3, tolerance) => + { + PathEl::LineTo(p3) + } + el => el, + }; + match el { + PathEl::MoveTo(p) => { + start = p; + last = p; + } + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => last = p, + PathEl::ClosePath => last = start, + } + out.push(el); + } + out +} + +/// Whether `p` is within `tolerance` of the segment from `a` to `b`. False for non-finite input. +fn near_segment(p: kurbo::Point, a: kurbo::Point, b: kurbo::Point, tolerance: f64) -> bool { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { + ((p - a).dot(ab) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + (p - (a + ab * t)).hypot() <= tolerance +} + /// Expand a stroked path to a filled path. pub fn expand_stroke( path: impl IntoIterator, @@ -284,3 +335,74 @@ impl Callback for FlattenerCallback<'_> { } } } + +#[cfg(test)] +mod tests { + use super::{FlattenCtx, Line, TOL, expand_stroke, stroke}; + use crate::geometry::RectU16; + use crate::kurbo::{Affine, BezPath, Point, Stroke, StrokeCtx}; + use alloc::vec::Vec; + use fearless_simd::Level; + + fn stroke_lines(path: &BezPath, width: f64, ctx: &mut StrokeCtx) -> Vec { + let mut lines = Vec::new(); + stroke( + Level::baseline(), + path, + &Stroke::new(width), + Affine::IDENTITY, + &mut lines, + &mut FlattenCtx::default(), + ctx, + RectU16::new(0, 0, 200, 200), + ); + lines + } + + // An outline in an Illustrator file (#4907) has a cubic whose control points are all equal + // and 0.001 from its start. kurbo's stroker gives that curve a NaN join, which made the whole + // outline disappear. The trigger depends on these exact coordinates. + #[test] + fn a_tiny_degenerate_cubic_does_not_drop_the_stroke() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((73.763, -0.536)); + let p = Point::new(73.764, -0.536); + path.curve_to(p, p, p); + + let mut ctx = StrokeCtx::default(); + let lines = stroke_lines(&path, 0.34, &mut ctx); + assert!(!lines.is_empty(), "the stroke was dropped"); + assert!(ctx.output().is_finite()); + let max_x = lines + .iter() + .map(|l| l.p0.x.max(l.p1.x)) + .fold(f32::MIN, f32::max); + assert!(max_x > 73.0, "the stroke ends at x = {max_x}"); + } + + #[test] + fn a_path_with_nan_is_still_dropped() { + let mut path = BezPath::new(); + path.move_to((10.0, 10.0)); + path.curve_to((f64::NAN, 10.0), (10.0, 60.0), (60.0, 60.0)); + let lines = stroke_lines(&path, 2.0, &mut StrokeCtx::default()); + assert!(lines.is_empty()); + } + + // A regression guard: it passes with or without the patch. + #[test] + fn a_finite_outline_is_stroked_as_before() { + let mut path = BezPath::new(); + path.move_to((10.0, 10.0)); + path.curve_to((60.0, 10.0), (10.0, 60.0), (60.0, 60.0)); + let style = Stroke::new(2.0); + let mut expected = StrokeCtx::default(); + expand_stroke(&path, &style, TOL, &mut expected); + + let mut ctx = StrokeCtx::default(); + let lines = stroke_lines(&path, 2.0, &mut ctx); + assert!(!lines.is_empty()); + assert_eq!(ctx.output(), expected.output()); + } +} diff --git a/vendor/vello_common/src/strip_generator.rs b/vendor/vello_common/src/strip_generator.rs index 9a8bfc237b..dcf9d4515a 100644 --- a/vendor/vello_common/src/strip_generator.rs +++ b/vendor/vello_common/src/strip_generator.rs @@ -144,7 +144,8 @@ impl StripGenerator { /// Generate the strips for a stroked path. pub fn generate_stroked_path( &mut self, - path: impl IntoIterator, + // PdfCraft patch: `Clone` for `flatten::stroke`. + path: impl IntoIterator + Clone, stroke: &Stroke, transform: Affine, aliasing_threshold: Option, From 38c948219119510c456a1104089a2b52fa365924 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 05:00:44 -0400 Subject: [PATCH 025/120] fix(xfa, create): read a BMP's pixels from after its headers when its offset points into them LiveCycle Designer wrote BMPs whose file header gives bfOffBits = 0. image (0.25) seeks to that offset as given, so the pixels were read from the file header on and the picture was drawn shifted and wrapped around (#4937). `pdfcraft_xfa::image::bmp_with_pixel_offset` recomputes the offset from the info header (its size, BI_BITFIELDS masks in a 40-byte header, the palette) when the declared one lies inside the headers and the computed one fits in the file; anything else is passed through unchanged and not copied. XFA pictures (size and decode) and `create::from_images` both go through it, so pdfcraft-create gains a dependency on pdfcraft-xfa (L4 -> L3). Fixes #4937 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 1 + crates/create/Cargo.toml | 2 ++ crates/create/src/lib.rs | 2 +- crates/create/src/tests.rs | 20 +++++++++++++ crates/xfa/src/image.rs | 57 ++++++++++++++++++++++++++++++++++++-- crates/xfa/src/tests.rs | 33 ++++++++++++++++++++++ 6 files changed, 112 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..2a8bb6ab7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4187,6 +4187,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-xfa", "png 0.18.1", "thiserror 2.0.21", "tiff", diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index fa649de47d..12daad621c 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } +# For `image::bmp_with_pixel_offset`, shared with XFA pictures. +pdfcraft-xfa = { workspace = true } png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 02bccd5fa8..a6fb28b3d6 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -562,7 +562,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result, Creat } else if is_tiff(bytes) { tiff_pages(name, bytes, wanted) } else if bytes.starts_with(b"BM") { - Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) + Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?]) } else if is_gif(bytes) { Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?]) } else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 3c9aa42a0e..5cd34a0f1a 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() { assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..)))); } +/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as +/// the intact file, not shifted by the header bytes. +#[test] +fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() { + use image::ImageEncoder; + let mut bmp = Vec::new(); + let pixels: Vec = (0..18).map(|i| i * 13).collect(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let mut zero = bmp.clone(); + zero[10..14].copy_from_slice(&0u32.to_le_bytes()); + let samples = |data: Vec| { + let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap()); + let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") }; + s.decoded().unwrap() + }; + assert_eq!(samples(zero), samples(bmp)); +} + /// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page /// (3.6 GB of gray pixels) must still be refused, not allocated. #[test] diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs index 5ec42873ed..8f1953bedd 100644 --- a/crates/xfa/src/image.rs +++ b/crates/xfa/src/image.rs @@ -41,6 +41,58 @@ fn is_bmp(data: &[u8]) -> bool { && data.get(14..18).is_some_and(|b| matches!(u32::from_le_bytes([b[0], b[1], b[2], b[3]]), 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124)) } +/// `data` with a BMP's pixel-data offset (`bfOffBits`) corrected when it points into the file or +/// info header, as some writers leave it at 0: the rows then start after the headers, the colour +/// masks and the colour table. The `image` decoder seeks to the declared offset unchecked, so +/// such a picture would be read from the wrong place, shifted and wrapped. Anything else, +/// including a header too short to measure, is returned as it is. +pub fn bmp_with_pixel_offset(data: &[u8]) -> std::borrow::Cow<'_, [u8]> { + match bmp_pixel_offset_repair(data) { + Some(offset) => { + let mut fixed = data.to_vec(); + if let Some(field) = fixed.get_mut(10..14) { + field.copy_from_slice(&offset.to_le_bytes()); + } + std::borrow::Cow::Owned(fixed) + } + None => std::borrow::Cow::Borrowed(data), + } +} + +/// Where a BMP's rows start when its declared offset lies inside its headers, else `None`. +fn bmp_pixel_offset_repair(data: &[u8]) -> Option { + let le16 = |at: usize| data.get(at..at + 2).map(|b| u32::from(u16::from_le_bytes([b[0], b[1]]))); + let le32 = |at: usize| data.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + if !is_bmp(data) { + return None; + } + let (declared, info_size) = (le32(10)?, le32(14)?); + let headers_end = info_size.checked_add(14)?; + if declared >= headers_end { + return None; + } + let (bpp, masks, used, entry) = if info_size == 12 { + // BITMAPCOREHEADER: 16-bit fields, no colour count, 3-byte table entries. + (le16(24)?, 0, 0, 3) + } else { + // BI_BITFIELDS and BI_ALPHABITFIELDS put their masks after a 40-byte header; the larger + // headers hold them inside. + let masks = match (info_size, le32(30)?) { + (40, 3) => 12, + (40, 6) => 16, + _ => 0, + }; + (le16(28)?, masks, le32(46)?, 4) + }; + let entries = match (used, bpp) { + (0, 1..=8) => 1 << bpp, + (0, _) => 0, + (n, _) => n, + }; + let offset = entries.checked_mul(entry)?.checked_add(masks)?.checked_add(headers_end)?; + (usize::try_from(offset).ok()? <= data.len()).then_some(offset) +} + /// The pixel size, from the header alone; `None` when it can't be read or the image is bigger /// than can be decoded (so layout gives such a picture its box, not a page-high strip). pub fn size(data: &[u8]) -> Option<(u32, u32)> { @@ -70,7 +122,7 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> { (w > 0 && h > 0).then_some((w, h)) } // The decoders read only the headers (TIFF: the first IFD) until asked for pixels. - Kind::Bmp => reader(data, image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0), + Kind::Bmp => reader(&bmp_with_pixel_offset(data), image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0), Kind::Tiff => { use image::{ImageDecoder, metadata::Orientation}; let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder().ok()?; @@ -174,7 +226,8 @@ pub fn decode(data: &[u8]) -> Result { Kind::Bmp => image::ImageFormat::Bmp, _ => image::ImageFormat::Tiff, }; - let img = if k == Kind::Tiff { decode_tiff(data) } else { reader(data, format).decode() } + let data = if k == Kind::Bmp { bmp_with_pixel_offset(data) } else { std::borrow::Cow::Borrowed(data) }; + let img = if k == Kind::Tiff { decode_tiff(&data) } else { reader(&data, format).decode() } .map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?; let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 39a1c93483..f8c0c6dd78 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1794,6 +1794,39 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000)); } +/// #4937: LiveCycle wrote BMPs whose header gives the pixel data's offset as 0. Read literally, +/// the pixels start at the file header, so the image is drawn shifted and wrapped around. +#[test] +fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() { + use crate::image::{bmp_with_pixel_offset, decode, size}; + for palette in [false, true] { + let intact = tiny_bmp(palette); + assert!(matches!(bmp_with_pixel_offset(&intact), std::borrow::Cow::Borrowed(_)), "an intact file is not copied"); + for bad in [0u32, 14, 53] { + let mut bmp = intact.clone(); + bmp[10..14].copy_from_slice(&bad.to_le_bytes()); + let d = decode(&bmp).unwrap(); + assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "offset {bad}, palette: {palette}"); + assert_eq!(size(&bmp), Some((2, 1))); + assert_eq!(bmp_with_pixel_offset(&bmp).as_ref(), &intact[..], "offset {bad}, palette: {palette}"); + } + } + // Too short to hold the pixels after the headers: left alone, not grown. + let mut cut = tiny_bmp(true); + cut[10..14].copy_from_slice(&0u32.to_le_bytes()); + cut.truncate(60); + assert_eq!(bmp_with_pixel_offset(&cut).as_ref(), &cut[..]); + // A truncated header, and bytes that are not a BMP: left alone. + assert_eq!(bmp_with_pixel_offset(b"BM").as_ref(), b"BM"); + assert_eq!(bmp_with_pixel_offset(TINY_GIF).as_ref(), TINY_GIF); + // BI_BITFIELDS in a 40-byte header: three masks follow the header. + let mut fields = tiny_bmp(false); + fields[30..34].copy_from_slice(&3u32.to_le_bytes()); + fields[10..14].copy_from_slice(&0u32.to_le_bytes()); + fields.splice(54..54, [0u8; 12]); + assert_eq!(bmp_with_pixel_offset(&fields).get(10..14), Some(&66u32.to_le_bytes()[..])); +} + /// Designer forms often carry BMP and TIFF pictures, declared as `image/bmp` and `image/tif` /// (the Government of Canada's IMM 5257 and IMM 5710 have both). They were left out. #[test] From de9ab5370e93ef1e274bb175aa46585b6d99a9ae Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 05:03:47 -0400 Subject: [PATCH 026/120] fix(xfa): leave a 16-byte BMP header to the decoder; test core headers and a huge colour count Review follow-up: the repair reads the 40-byte header's compression and colour count, which a 16-byte OS/2 header does not have. New cases pin a 12-byte core header (3-byte palette entries) and a biClrUsed of u32::MAX. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/image.rs | 3 +++ crates/xfa/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs index 8f1953bedd..a284b444a8 100644 --- a/crates/xfa/src/image.rs +++ b/crates/xfa/src/image.rs @@ -74,6 +74,9 @@ fn bmp_pixel_offset_repair(data: &[u8]) -> Option { let (bpp, masks, used, entry) = if info_size == 12 { // BITMAPCOREHEADER: 16-bit fields, no colour count, 3-byte table entries. (le16(24)?, 0, 0, 3) + } else if info_size < 40 { + // OS/2's 16-byte header ends before the colour count; leave it to the decoder. + return None; } else { // BI_BITFIELDS and BI_ALPHABITFIELDS put their masks after a 40-byte header; the larger // headers hold them inside. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f8c0c6dd78..8926c12b2a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1825,6 +1825,23 @@ fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() { fields[10..14].copy_from_slice(&0u32.to_le_bytes()); fields.splice(54..54, [0u8; 12]); assert_eq!(bmp_with_pixel_offset(&fields).get(10..14), Some(&66u32.to_le_bytes()[..])); + // A colour count no file could hold: left alone. + let mut huge = tiny_bmp(true); + huge[46..50].copy_from_slice(&u32::MAX.to_le_bytes()); + huge[10..14].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(bmp_with_pixel_offset(&huge).as_ref(), &huge[..]); + // A 12-byte core header: 16-bit fields and 3-byte palette entries, `1 << bpp` of them. + let mut core = b"BM".to_vec(); + core.extend_from_slice(&[0; 8]); + core.extend_from_slice(&0u32.to_le_bytes()); + core.extend_from_slice(&12u32.to_le_bytes()); + for v in [2u16, 1, 1, 1] { + core.extend_from_slice(&v.to_le_bytes()); + } + core.extend_from_slice(&[0, 0, 255, 255, 0, 0, 0b0100_0000, 0, 0, 0]); + assert_eq!(bmp_with_pixel_offset(&core).get(10..14), Some(&32u32.to_le_bytes()[..])); + let d = decode(&bmp_with_pixel_offset(&core)).unwrap(); + assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "core header"); } /// Designer forms often carry BMP and TIFF pictures, declared as `image/bmp` and `image/tif` From fd8c8c185d38169980c00d517eceac8d65601ed3 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 05:43:31 -0400 Subject: [PATCH 027/120] fix(xfa): repair a BMP offset that points at the header's end with masks or a palette to follow Review follow-up: an offset equal to the end of the info header is only right when no bitfield masks or colour table come after it; otherwise the pixels were read from the masks or the table. It is now repaired like an offset inside the headers. Offsets strictly inside the masks or table are still taken as given (they may come from an overstated biClrUsed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/image.rs | 7 ++++--- crates/xfa/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs index a284b444a8..e41cbd90a9 100644 --- a/crates/xfa/src/image.rs +++ b/crates/xfa/src/image.rs @@ -42,7 +42,7 @@ fn is_bmp(data: &[u8]) -> bool { } /// `data` with a BMP's pixel-data offset (`bfOffBits`) corrected when it points into the file or -/// info header, as some writers leave it at 0: the rows then start after the headers, the colour +/// info header or at its end with masks or a colour table still to come, as some writers leave it at 0: the rows then start after the headers, the colour /// masks and the colour table. The `image` decoder seeks to the declared offset unchecked, so /// such a picture would be read from the wrong place, shifted and wrapped. Anything else, /// including a header too short to measure, is returned as it is. @@ -68,7 +68,8 @@ fn bmp_pixel_offset_repair(data: &[u8]) -> Option { } let (declared, info_size) = (le32(10)?, le32(14)?); let headers_end = info_size.checked_add(14)?; - if declared >= headers_end { + // At the end of the headers is right only when no masks or colour table follow. + if declared > headers_end { return None; } let (bpp, masks, used, entry) = if info_size == 12 { @@ -93,7 +94,7 @@ fn bmp_pixel_offset_repair(data: &[u8]) -> Option { (n, _) => n, }; let offset = entries.checked_mul(entry)?.checked_add(masks)?.checked_add(headers_end)?; - (usize::try_from(offset).ok()? <= data.len()).then_some(offset) + (offset != declared && usize::try_from(offset).ok()? <= data.len()).then_some(offset) } /// The pixel size, from the header alone; `None` when it can't be read or the image is bigger diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 8926c12b2a..8086e36bcc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1825,6 +1825,9 @@ fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() { fields[10..14].copy_from_slice(&0u32.to_le_bytes()); fields.splice(54..54, [0u8; 12]); assert_eq!(bmp_with_pixel_offset(&fields).get(10..14), Some(&66u32.to_le_bytes()[..])); + // Pointing at the end of the 40-byte header, before the masks. + fields[10..14].copy_from_slice(&54u32.to_le_bytes()); + assert_eq!(bmp_with_pixel_offset(&fields).get(10..14), Some(&66u32.to_le_bytes()[..])); // A colour count no file could hold: left alone. let mut huge = tiny_bmp(true); huge[46..50].copy_from_slice(&u32::MAX.to_le_bytes()); @@ -1840,6 +1843,9 @@ fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() { } core.extend_from_slice(&[0, 0, 255, 255, 0, 0, 0b0100_0000, 0, 0, 0]); assert_eq!(bmp_with_pixel_offset(&core).get(10..14), Some(&32u32.to_le_bytes()[..])); + let mut at_table = core.clone(); + at_table[10..14].copy_from_slice(&26u32.to_le_bytes()); + assert_eq!(bmp_with_pixel_offset(&at_table).get(10..14), Some(&32u32.to_le_bytes()[..]), "before the colour table"); let d = decode(&bmp_with_pixel_offset(&core)).unwrap(); assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "core header"); } From 32df17722c40e5adc263bd8571098914ff326d10 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:43 +0000 Subject: [PATCH 028/120] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 +++ packaging/windows/check-install-policy.ps1 | 195 ++++++++++++ packaging/windows/install-policy.json | 329 +++++++++++++++++++++ packaging/windows/install-policy.wxi | 54 ++++ packaging/windows/installer-ui.wxs | 26 +- packaging/windows/package.ps1 | 2 + packaging/windows/pdfcraft.wxs | 180 +++++------ packaging/windows/test-install-policy.ps1 | 54 ++++ packaging/windows/test-msi.ps1 | 68 ++--- 10 files changed, 798 insertions(+), 164 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..7e847137f1 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..dd28956435 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..374bd4e5fe --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,329 @@ +{ + "id": "pdfcraft", + "name": "PdfCraft", + "upgradeCode": "EA00C367-7270-4C50-9D8B-A7A5C88AD27F", + "sources": [ + "pdfcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/pdfcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "LICENSE-document.txt", + "NOTICE", + "pdfcraft-cli.exe", + "pdfcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pdfcraft.exe", + "", + "[#PdfcraftExe]" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "", + "PDF Document" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "TypeOverlay", + "" + ], + [ + "-1", + "Software\\Classes\\.pdf\\OpenWithProgids", + "PdfCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe", + "FriendlyAppName", + "PdfCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\SupportedTypes", + ".pdf", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationName", + "PdfCraft" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationDescription", + "Open-source PDF workbench" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationIcon", + "[#PdfcraftExe],0" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities\\FileAssociations", + ".pdf", + "PdfCraft.Document" + ], + [ + "-1", + "Software\\RegisteredApplications", + "PdfCraft", + "Software\\PdfCraft\\Capabilities" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "StartMenu", + "#1" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "Desktop", + "#1" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "", + "Edit with PdfCraft" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "Icon", + "\"[#PdfcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "MultiSelectModel", + "Document" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..e1a51f80f3 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index a437c2a8c7..cb7b486d9e 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,6 +1,4 @@ - @@ -10,12 +8,9 @@ - - - + + + @@ -26,8 +21,7 @@ - + @@ -66,8 +60,7 @@ - + @@ -75,8 +68,7 @@ - + @@ -84,8 +76,7 @@ - + @@ -96,8 +87,7 @@ continues; Cancel ends setup. --> - + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index dccc21f559..e9b9e4432b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -154,6 +154,8 @@ Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 65e76f254b..c5881401db 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -1,37 +1,16 @@ - - - + + + - - + - + - - + + - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..21199ca7f4 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 24a4c6d75a..59b1c13696 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -38,41 +38,11 @@ function Assert-NoRow([string] $Sql, [string] $What) { } finally { [void] $view.Close() } } -# Test the compiled condition with Windows Installer's evaluator, in a restricted session that -# cannot change machine state. Normal installs/repairs work, per-user overrides fail, and removal -# of an older incorrectly scoped installation remains possible (#305). -$scopeMessage = 'PdfCraft must be installed for all users. Run setup with administrator privileges and ALLUSERS=1; per-user installation is not supported.' -$scopeCondition = Read-Row ('SELECT `Condition` FROM `LaunchCondition` WHERE `Description` = ''' + $scopeMessage + '''') 1 +# The shared compiled contract covers both scopes, duplicate guards and safe maintenance. +# Keep it in a separate process because Windows Installer permits one session per process. +& (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Path +if ($LASTEXITCODE -ne 0) { throw 'Dual-scope installer contract failed.' } $Installer.UILevel = 2 -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('machine install', '1', '', '', '', 1), - @('machine repair', '1', '', '1', '', 1), - @('forced per-user install', '2', '1', '', '', 0), - @('empty ALLUSERS', '', '', '', '', 0), - @('resolved per-user install', '', '1', '', '', 0), - @('forced per-user repair', '2', '1', '1', '', 0), - @('machine uninstall', '1', '', '1', 'ALL', 1), - @('legacy per-user uninstall', '', '1', '1', 'ALL', 1))) { - $session.Property('ALLUSERS') = $case[1] - $session.Property('MSIINSTALLPERUSER') = $case[2] - $session.Property('Installed') = $case[3] - $session.Property('REMOVE') = $case[4] - Assert-Equal ($session.EvaluateCondition($scopeCondition[0])) $case[5] $case[0] - } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } -} -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $launch = Read-Row ('SELECT `Condition`, `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''LaunchConditions''') 2 - Assert-Equal $launch[0] '' "$sequence launch conditions are unconditional" - $cost = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''CostInitialize''') 1 - if ([int] $launch[1] -le 0 -or [int] $launch[1] -ge [int] $cost[0]) { - throw "$sequence must reject per-user overrides before costing" - } -} $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' @@ -173,7 +143,7 @@ Assert-Equal $checked[0] '1' 'Welcome checkbox value' $app = Read-Row 'SELECT `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftApp''' 1 Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path' $scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1 -Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' +Assert-Equal $scope[0] '2' 'Dual-scope shortcut package' # Each File Explorer menu remembers whether it was installed by finding its key path in HKLM, in the # package's own registry view (64-bit for x64 and arm64). Only a release that offered the menu can @@ -181,12 +151,22 @@ Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' $upgradeCode = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode''' 1 $view64 = $Database.SummaryInformation(0).Property(7) -notmatch '^Intel;' function Assert-MenuSearch([string] $Found, [string] $Key, [string] $Release, [string] $Since, [string] $What) { - $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $Found + '''') 4 - Assert-Equal $search[0] '2' "$What search HKLM root" - Assert-Equal $search[1] $Key "$What search key" - Assert-Equal $search[2] '' "$What search reads the default value" - # msidbLocatorTypeRawValue (2), plus msidbLocatorType64bit (16) in a 64-bit package. - Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + foreach ($context in @(@('M', '2'), @('U', '1'))) { + $cache = '' + $loaders = $Database.OpenView('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Found + '''') + try { + [void]$loaders.Execute() + while ($row = $loaders.Fetch()) { + if ($row.StringData(1) -match ('^CraftLoadOption[0-9]+' + $context[0] + '$')) { $cache = $row.StringData(2).Trim('[]') } + } + } finally { [void]$loaders.Close() } + if (-not $cache) { throw "$What has no context-specific loader." } + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $cache + '''') 4 + Assert-Equal $search[0] $context[1] "$What context $($context[0]) search root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + } $upgrade = Read-Row ('SELECT `UpgradeCode`, `VersionMin`, `VersionMax`, `Attributes` FROM `Upgrade` WHERE `ActionProperty` = ''' + $Release + '''') 4 Assert-Equal $upgrade[0] $upgradeCode[0] "$What release detection upgrade code" Assert-Equal $upgrade[1] $Since "$What release detection minimum" @@ -213,7 +193,7 @@ foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2 $menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3 Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label" Assert-Equal $menu[1] 'PdfcraftImageContextMenu' "$ext context menu component" - Assert-Equal $menu[2] '2' "$ext context menu HKLM root" + Assert-Equal $menu[2] '-1' "$ext context menu context-sensitive root" $command = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 2 Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command" Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" @@ -246,7 +226,7 @@ $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' $pdfMenu = Read-Row ('SELECT `Registry`, `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` IS NULL') 4 Assert-Equal $pdfMenu[1] 'Edit with PdfCraft' 'PDF context menu label' Assert-Equal $pdfMenu[2] 'PdfcraftPdfContextMenu' 'PDF context menu component' -Assert-Equal $pdfMenu[3] '2' 'PDF context menu HKLM root' +Assert-Equal $pdfMenu[3] '-1' 'PDF context menu context-sensitive root' foreach ($value in @(@('Icon', '"[#PdfcraftExe]",0'), @('MultiSelectModel', 'Document'))) { $row = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` = ''' + $value[0] + '''') 2 Assert-Equal $row[0] $value[1] "PDF context menu $($value[0])" @@ -299,4 +279,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: dual-scope guards, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' From c39a0f36586a9a04e0996cda47732afea6e822ce Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:49:45 +0000 Subject: [PATCH 029/120] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- packaging/windows/installer-ui.wxs | 21 +++++-- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d470419d26..85238e99d7 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: pdfcraft app-dir: PdfCraft + install-relative-path: Programs/ArtCraft/PdfCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd28956435..00b683d708 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index e1a51f80f3..79e46664cb 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index cb7b486d9e..7bea8562f8 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -8,10 +8,14 @@ - + + - + + + + @@ -22,12 +26,21 @@ - + - + + + + + + + + + + From c1670d0aa535226e6567f461a62d81eebfe2d4c5 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:46:22 +0000 Subject: [PATCH 030/120] fix(installer): keep the desktop shortcut on a machine-wide update; harden install-policy checks --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/pdfcraft.wxs | 15 ++++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7e847137f1..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -35,8 +37,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d708..451295a2e4 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index c5881401db..44f6012ba0 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -27,12 +27,21 @@ updates with /passive and no properties, so deleting the shortcut is the only way its users can say no. A value given on the command line wins. --> - - + + + - + + + + + + + + + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From 5abf32ed2f067e02f0889f439d2bd58dee64a02d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 12:03:33 -0400 Subject: [PATCH 044/120] fix(render): knockout passes clear a masked image's coverage, and nothing for an empty mesh Review of #4898 (FredApps): - An image or nested group of another blend mode than the pass's composited its layer at opacity 0, on the assumption that its shape was cleared first. `knockout_image` skips images with an `/SMask` and stencil masks, so a white Difference square stayed under a blue Normal masked image in the Difference pass and turned it yellow. The layer now composites with `Compose::DestOut` at full opacity and no soft mask, clearing exactly its coverage. - In another pass (and an erase pass), a shading cleared under its path before the mesh-extent check that makes an off-page mesh paint nothing, so a Difference `sh` of such a mesh erased the blue under it. The clear now makes the same decision (`shading_bbox`, `mesh_area`, shared with the paint path). Tests: `it::knockout_backdrop` gains a masked image, a stencil and an off-page mesh case; each fails with its half of the fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/knockout_backdrop.rs | 40 +++++++- vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 101 ++++++++++++-------- 3 files changed, 102 insertions(+), 41 deletions(-) diff --git a/crates/render/tests/it/knockout_backdrop.rs b/crates/render/tests/it/knockout_backdrop.rs index 126de8a713..2934068f2d 100644 --- a/crates/render/tests/it/knockout_backdrop.rs +++ b/crates/render/tests/it/knockout_backdrop.rs @@ -35,7 +35,9 @@ fn stream(dict: &str, content: &str) -> String { /// content `content`. Its resources have `/D` (`/BM /Difference`), `/N` (`/BM /Normal`), `/H` /// (`/ca 0.5`), `/W`, a white square from 40 to 160 in a group of its own, `/Nest`, a group /// holding a non-isolated knockout group with a white Difference square from 80 to 120, and -/// `/Img`, a white 1 x 1 image. +/// `/Img`, a white 1 x 1 image, `/Img2`, a blue 2 x 1 image whose soft mask is opaque on the +/// left and transparent on the right, `/St`, a 2 x 1 stencil mask painting its left pixel, and +/// the shading `/Sh0`, a mesh whose one triangle lies off the page. /// Returns a lookup of the colour at PDF point (x, y). fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>"; @@ -46,7 +48,7 @@ fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { .to_string(), stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"), stream( - &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R >> >>"), + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R >> >>"), content, ), stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"), @@ -56,6 +58,16 @@ fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { "/D gs 1 1 1 rg 80 80 40 40 re f", ), stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FFFFFF>"), + stream( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode", + "0000FF0000FF>", + ), + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"), + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true /BitsPerComponent 1 /Filter /ASCIIHexDecode", "40>"), + stream( + "/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [300 310 300 310 0 1 0 1 0 1] /Filter /ASCIIHexDecode", + "000000FF0000 00FF00FF0000 0000FFFF0000>", + ), ]; let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default()); let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); @@ -131,3 +143,27 @@ fn a_knockout_group_inside_an_element_keeps_the_outer_groups_passes() { check(&nested, 30, 30, GREEN, "Difference square before the nested groups"); check(&nested, 165, 165, GREEN, "Difference square after the nested groups"); } + +/// A masked image (or, below, a stencil) in another pass than its own replaces what is under its mask +/// there too: a blue image over a white Difference square stays blue where its mask is opaque, +/// and leaves the square to blend with the page where it is transparent (review of #4886's fix). +#[test] +fn a_masked_image_replaces_what_is_under_its_mask_in_every_pass() { + let masked = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs q 120 0 0 120 40 40 cm /Img2 Do Q"); + check(&masked, 60, 100, [0, 0, 255], "image over the square, mask opaque"); + check(&masked, 140, 100, GREEN, "square under the transparent half of the mask"); +} + +#[test] +fn a_stencil_replaces_what_is_under_it_in_every_pass() { + let stencil = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs 0 0 1 rg q 120 0 0 120 40 40 cm /St Do Q"); + check(&stencil, 60, 100, [0, 0, 255], "stencil over the square"); + check(&stencil, 140, 100, GREEN, "square beside the stencil"); +} + +/// A shading that paints nothing (a mesh off the page) replaces nothing in another pass either. +#[test] +fn a_shading_that_paints_nothing_clears_nothing_in_another_pass() { + let mesh = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /Sh0 sh"); + check(&mesh, 100, 100, [0, 0, 255], "blue under an off-page Difference mesh"); +} diff --git a/vendor/README.md b/vendor/README.md index b1dcd406bb..5efa0ca2e3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (67) a non-isolated knockout group entered at full opacity, without a soft mask and with Normal blending is drawn once per blend mode its elements use (`Device::begin_knockout_pass`, `end_knockout_pass`), each pass a layer composited onto the backdrop in that mode, so each element blends with the group's backdrop in its own blend mode (ISO 32000-2 §11.4.7, §11.4.8; #4886): drawn as one layer starting transparent, white with `/BM /Difference` over magenta stayed white; each pass starts from the same marked-content visibility; inside an element's knockout erase pass a nested group takes one pass, leaving the outer group's passes alone; a masked image or stencil of another mode clears nothing under it, as in (39) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (67) `it::knockout_backdrop` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (26) the renderer side of hayro-interpret (67): in a blend-mode pass over a knockout group, an element of another blend mode clears what is under its shape instead of painting (a path, glyph or shading through `set_paint`; an image or nested group's layer composites at opacity 0 after its shape was cleared), and the pass reports the other modes its elements used | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (26) `it::knockout_backdrop` (`cargo test -p pdfcraft-render --test it`) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (26) the renderer side of hayro-interpret (67): in a blend-mode pass over a knockout group, an element of another blend mode clears what is under its shape instead of painting (a path, glyph or shading through `set_paint`, where a mesh that paints nothing clears nothing; an image or nested group's layer composites with `Compose::DestOut`, clearing its coverage, a masked image's or stencil's mask included), and the pass reports the other modes its elements used | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (26) `it::knockout_backdrop` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 2524479eb9..887493997a 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1130,6 +1130,25 @@ impl Renderer { } } + /// The device-space area a shading painted on `path` covers: the path's bounding box, with + /// the stroke width around it for a stroke, on the canvas. + fn shading_bbox(&self, path: &BezPath, is_stroke: bool) -> Rect { + let path_transform = self.ctx.transform(); + let mut bbox = (*path_transform * path.clone()).bounding_box(); + if is_stroke { + // Try to account for stroke in bbox. + let (a1, a2) = x_y_advances(path_transform); + let factor = a1.length().max(a2.length()) * self.ctx.stroke().width; + bbox = bbox.inflate(factor, factor); + } + bbox.intersect(Rect::new( + 0.0, + 0.0, + self.ctx.width() as f64, + self.ctx.height() as f64, + )) + } + #[must_use] fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { // PdfCraft patch (22): in a knockout erase pass every paint clears what is under its @@ -1138,16 +1157,25 @@ impl Renderer { let other_pass = !self.erasing && element_mode.is_some_and(|mode| self.knockout_pass_skips(mode)); if self.erasing || other_pass { - self.ctx.set_blend_mode(knockout_blend_mode()); - self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); - // A shading's `/BBox` clips what it paints, so it clips what it clears too. - return PaintSetup::Clip(match paint { + // A shading's `/BBox` clips what it paints, so it clips what it clears too, and a + // mesh that paints nothing (below) clears nothing. + let clip = match paint { Paint::Pattern(p) => match p.as_ref() { - Pattern::Shading(s) => s.shading.clip_path.clone(), + Pattern::Shading(s) => { + let bbox = self.shading_bbox(path, is_stroke); + let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0))); + if mesh_area(bbox, &encoded).is_none() { + return PaintSetup::Nothing; + } + s.shading.clip_path.clone() + } _ => None, }, _ => None, - }); + }; + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + return PaintSetup::Clip(clip); } let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; @@ -1166,21 +1194,7 @@ impl Renderer { match *p { Pattern::Shading(s) => { clip_path = s.shading.clip_path.clone(); - let mut bbox = (*path_transform * path.clone()).bounding_box(); - - if is_stroke { - // Try to account for stroke in bbox. - let (a1, a2) = x_y_advances(path_transform); - let factor = a1.length().max(a2.length()) * self.ctx.stroke().width; - bbox = bbox.inflate(factor, factor); - } - - bbox = bbox.intersect(Rect::new( - 0.0, - 0.0, - self.ctx.width() as f64, - self.ctx.height() as f64, - )); + let mut bbox = self.shading_bbox(path, is_stroke); // PdfCraft patch: mesh shadings are sampled only inside `bbox` (and a // pixel around it: texels at a fractional edge look one pixel further). @@ -1193,18 +1207,11 @@ impl Renderer { // now covers the triangles alone (a pixel around them, which stays // transparent and is what `Extend::Pad` repeats), and a mesh with no // triangle on the canvas paints nothing at all. - if let Some(extent) = encoded.extent { - // An empty extent (no triangle in the area) is `Rect::ZERO`, which - // sits at the canvas origin: test it before intersecting, or a - // 1 × 1 transparent texture would still be painted over the whole - // path (310 of them cost 230 ms per tile). - if extent.width() <= 0.0 || extent.height() <= 0.0 { + if encoded.extent.is_some() { + let Some(area) = mesh_area(bbox, &encoded) else { return PaintSetup::Nothing; - } - bbox = bbox.intersect(extent.inflate(1.0, 1.0)); - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - return PaintSetup::Nothing; - } + }; + bbox = area; extend = peniko::Extend::Pad; } @@ -1745,12 +1752,14 @@ impl<'a> Device<'a> for Renderer { self.erase_groups += 1; return; } - // PdfCraft patch (26): an image or group of another pass than the current one composites - // nothing; what it covers was cleared before it (`knockout_image`, the erase pass). - let opacity = if self.knockout_pass_skips(blend_mode) { - 0.0 + // PdfCraft patch (26): an image or group of another pass than the current one clears + // what is under its shape instead of painting: its layer, drawn at full opacity with no + // soft mask, composites with `Compose::DestOut`. That clears the coverage of a masked + // image's or a stencil's mask too, which `knockout_image` leaves alone. + let (opacity, mask, layer_blend) = if self.knockout_pass_skips(blend_mode) { + (1.0, None, peniko::BlendMode::new(Mix::Normal, Compose::DestOut)) } else { - opacity + (opacity, mask, convert_blend_mode(blend_mode)) }; let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() @@ -1762,7 +1771,7 @@ impl<'a> Device<'a> for Renderer { .unwrap_or(false); self.ctx.push_layer( None, - Some(convert_blend_mode(blend_mode)), + Some(layer_blend), Some(opacity), // TODO: Deduplicate mask.map(|m| { @@ -2207,6 +2216,22 @@ fn knockout_blend_mode() -> peniko::BlendMode { peniko::BlendMode::new(Mix::Normal, Compose::Copy) } +/// PdfCraft patch (19): the part of `bbox` a shading encoded within it paints. A mesh paints +/// only around its triangles, and nothing (`None`) when none is in `bbox`; its empty extent is +/// `Rect::ZERO`, at the canvas origin, so it is tested before intersecting, or a 1 × 1 +/// transparent texture would still be painted over the whole path (310 of them cost 230 ms per +/// tile). Any other shading paints all of `bbox`. +fn mesh_area(bbox: Rect, encoded: &EncodedShadingPattern) -> Option { + let Some(extent) = encoded.extent else { + return Some(bbox); + }; + if extent.width() <= 0.0 || extent.height() <= 0.0 { + return None; + } + let area = bbox.intersect(extent.inflate(1.0, 1.0)); + (area.width() > 0.0 && area.height() > 0.0).then_some(area) +} + fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { let mix = match blend_mode { BlendMode::Normal => Mix::Normal, From ea8b729925e035faf0fc0fe2e7f636153fa64941 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 12:20:30 -0400 Subject: [PATCH 045/120] M6: a joined radio button stays selected, keeps its actions, and always gets a free value Review follow-ups on #4977: - ui-egui: after a rename, the Properties dialog re-selects the edited widget by object, not by its old index. A button that joins a group is appended after the group's own buttons, so the old index selected the group's first button and a nudge moved the wrong one. - split_merged (join and Duplicate): the new widget takes /A and the widget triggers of /AA (E X D U Fo Bl PO PC PV PI); the field keeps K F V C. - join: a clashing export value searches at most taken + 1 ChoiceN names, so one is always free, and the names are chosen before anything is written. Tests: a_joined_merged_groups_button_keeps_its_actions, a_joining_button_gets_a_free_export_value_when_choice_names_run_long, a_radio_button_joining_a_group_by_name_stays_selected (ui-egui). Each fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/join.rs | 40 +++++++------- crates/forms/src/author/lifecycle.rs | 32 ++++++++++- crates/forms/src/tests.rs | 81 ++++++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 10 +++- crates/ui-egui/tests/it/prepare.rs | 67 ++++++++++++++++++++++- 5 files changed, 199 insertions(+), 31 deletions(-) diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index f98bd42cb9..0e74b94be7 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -12,9 +12,6 @@ use super::*; /// stays: a widget's own triggers live there. const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; -/// The most "ChoiceN" names tried for a renamed export value. -const MAX_FRESH_STATES: usize = 10_000; - /// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). fn has_opt(doc: &Document, field: ObjRef) -> bool { doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) @@ -42,6 +39,24 @@ pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<() /// selected value. `f` leaves the form; its own value and field-level properties go with it, /// and `target`'s stay. Call [`check_join`] first. pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> { + let theirs: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + let mut taken = theirs.clone(); + taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone())); + // The group's value counts only when one of its buttons has it. + let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned(); + // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of + // `taken.len() + 1` names at least one is free, so the search always ends in one. + let mut renamed: HashMap = HashMap::new(); + for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) { + if renamed.contains_key(old) { + continue; + } + let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else { + return invalid(format!("no free export value for {old}")); + }; + taken.insert(fresh.clone()); + renamed.insert(old.to_owned(), fresh); + } // A group that is its own one button gets a field with a separate widget first, so it can // hold more than one. if let [only] = target.widgets.as_slice() @@ -49,23 +64,6 @@ pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> { split_merged(doc, target, only)?; } - let theirs: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); - let mut taken = theirs.clone(); - taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone())); - // The group's value counts only when one of its buttons has it. - let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned(); - // Each clashing value of `f` gets one fresh name, shared by every button that had it. - let mut renamed: HashMap> = HashMap::new(); - for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) { - if renamed.contains_key(old) { - continue; - } - let fresh = (1..=MAX_FRESH_STATES).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)); - if let Some(c) = &fresh { - taken.insert(c.clone()); - } - renamed.insert(old.to_owned(), fresh); - } detach(doc, f.obj)?; // `f` is no longer a field, even when it stays on as a widget. drop_from_calculation_order(doc, |r| r == f.obj)?; @@ -79,7 +77,7 @@ pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> } let mut state = w.on_state.clone(); if let Some(old) = w.on_state.as_deref() - && let Some(Some(fresh)) = renamed.get(old) + && let Some(fresh) = renamed.get(old) && rename_state(doc, &mut d, old, fresh) { state = Some(fresh.clone()); diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index c62b9bde2c..d23d28d95a 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -237,7 +237,23 @@ pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjR } /// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; +const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"]; + +/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`, +/// `F`, `V`, `C`, Table 199) belong to the field. +const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"]; + +/// Split additional-actions dictionary `aa` into the widget's triggers and the field's. +/// An empty half is `None`. +fn split_triggers(aa: &Dict) -> (Option, Option) { + let (mut widget, mut field) = (Dict::new(), Dict::new()); + for (k, v) in aa.iter() { + let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field }; + to.set(k.clone(), v.clone()); + } + let some = |d: Dict| (!d.is_empty()).then_some(d); + (some(widget), some(field)) +} /// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page /// gets a widget at the same place, belonging to the same field, so they share one value. @@ -303,12 +319,26 @@ pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Res kid.set(k.to_vec(), v.clone()); } } + // The widget's own triggers go with it; the field's stay. + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default(); + if let Some(w) = widget_aa { + kid.set(b"AA".to_vec(), Object::Dict(w)); + } kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); let kr = doc.add(Object::Dict(kid)); doc.update_dict(f.obj, |d| { for k in WIDGET_KEYS { d.remove(k); } + if aa.is_some() { + match field_aa { + Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)), + None => { + d.remove(b"AA"); + } + } + } d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); })?; // The page lists the widget now, not the field. diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4648167c4f..4bda036c48 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4676,15 +4676,14 @@ fn joining_radio_buttons_that_share_a_value_keep_sharing_it() { assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2", "Choice2"]); } -/// A radio field that is its own (only) widget, as other writers make one-button groups, can -/// both join a group and be joined. -#[test] -fn radio_fields_that_are_their_own_widget_join_and_are_joined() { +/// One page holding radio fields that are each their own only widget, as other writers make +/// one-button groups: `(name, export value, x)`. +fn merged_radios(specs: &[(&str, &str, f64)]) -> Document { let mut doc = one_page(); let page = page_refs(&doc)[0]; let af = crate::author::ensure_form(&mut doc).unwrap(); let mut made = Vec::new(); - for (name, state, x) in [("solo", "A", 50.0), ("other", "B", 80.0)] { + for &(name, state, x) in specs { let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); let mut n = pdfcraft_cos::Dict::new(); n.set(state.as_bytes().to_vec(), Object::Ref(look)); @@ -4711,6 +4710,14 @@ fn radio_fields_that_are_their_own_widget_join_and_are_joined() { made.push(r); } doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap(); + doc +} + +/// A radio field that is its own (only) widget, as other writers make one-button groups, can +/// both join a group and be joined. +#[test] +fn radio_fields_that_are_their_own_widget_join_and_are_joined() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo"); let doc = reopen(&doc); let all = fields(&doc); @@ -4729,6 +4736,70 @@ fn radio_fields_that_are_their_own_widget_join_and_are_joined() { assert_eq!(on_page, kids, "the page lists the widgets, not the field"); } +/// A group that is its own only widget keeps its button's actions when another button joins +/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own. +#[test] +fn a_joined_merged_groups_button_keeps_its_actions() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let solo = field(&fields(&doc), "solo").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"Fo".to_vec(), js("focus();")); + aa.set(b"V".to_vec(), js("validate();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(solo, |d| { + d.set(b"A".to_vec(), js("up();")); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + assert_eq!( + field_actions(&doc, "solo").unwrap(), + [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))] + ); + let group = field(&fields(&doc), "solo").clone(); + let fd = doc.get(group.obj).as_dict().unwrap().clone(); + let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger"); + let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V")); +} + +/// However many "ChoiceN" names a group already uses, a joining button with a clashing export +/// value gets a free one, so the two buttons can still be chosen apart. +#[test] +fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let group = field(&fields(&doc), "Group1").clone(); + let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut kids = vec![Object::Ref(group.widgets[0].obj)]; + for i in 2..=10_000 { + let mut n = pdfcraft_cos::Dict::new(); + n.set(format!("Choice{i}").into_bytes(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = model.clone(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + w.remove(b"P"); + kids.push(Object::Ref(doc.add(Object::Dict(w)))); + } + doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + let states: Vec = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + assert_eq!(states.len(), 10_001); + assert_eq!(states.last().map(String::as_str), Some("Choice10001")); +} + /// Only a radio button joins by name: other clashes are refused as before, and a locked group /// isn't changed. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d0e9482d06..2421af6573 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1527,6 +1527,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone()); // A rename also renames the field in the formulas that read it. let before = props.name.as_ref().and_then(|_| form(app)); + // The widget being edited, by object: a radio button that joins a group by its new name + // is appended after the group's own buttons, so its index changes. + let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj)); if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) }) && let Some(i) = app.active { @@ -1535,7 +1538,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let typed = d.name.trim(); let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); let name = format!("{prefix}{typed}"); - if let (Some(before), Some(after)) = (before, form(app)) { + let after = form(app); + let index = + widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget); + if let (Some(before), Some(after)) = (before, after) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { app.notify(crate::i18n::trn( @@ -1546,7 +1552,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { )); } } - app.views[i].prepare.selected = Some((name, d.widget)); + app.views[i].prepare.selected = Some((name, index)); } } if props_now && let Some(d) = app.comment_props.take() { diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 8d565ab696..e8deba404a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -775,14 +775,18 @@ fn actions_tab_edits_the_script_of_an_existing_action() { /// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur /// runs another script. fn chained_action_fixture() -> Vec { - let objs = [ + pdf_of(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", "<< /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 /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>", "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>", "<< /S /URI /URI (https://example.org) >>", - ]; + ]) +} + +/// A PDF of `objs`, numbered from 1, with the first as its catalog. +fn pdf_of(objs: &[&str]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -798,6 +802,65 @@ fn chained_action_fixture() -> Vec { out } +/// Two one-button radio groups placed apart on page 1: "Group1" (export `A`) and "Group2" (`B`). +fn two_radio_groups_fixture() -> Vec { + let widget = |parent: u32, x: u32, state: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [{x} 300 {} 314] /P 3 0 R /AS /Off /AP << /N << /{state} 8 0 R /Off 8 0 R >> >> >>", + x + 14 + ) + }; + let (a, b) = (widget(4, 40, "A"), widget(6, 120, "B")); + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 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 7 0 R] >>", + "<< /FT /Btn /Ff 49152 /T (Group1) /Kids [5 0 R] >>", + &a, + "<< /FT /Btn /Ff 49152 /T (Group2) /Kids [7 0 R] >>", + &b, + "<< /Length 3 >>\nstream\n0 g\nendstream", + ]) +} + +/// #4949: renaming a radio button to another group's name moves it into that group, after the +/// group's own buttons. The moved button stays the selected one, so a nudge moves it and not the +/// group's first button, and undo and redo take the rename and the nudge back and forth. +#[test] +fn a_radio_button_joining_a_group_by_name_stays_selected() { + let mut h = harness_with("radios.pdf", two_radio_groups_fixture()); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let rects = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let form = &s.session.get(s.views[0].id).unwrap().form; + form.iter().flat_map(|f| f.widgets.iter().map(|w| w.rect)).collect::>() + }; + let start = rects(&h); + h.state_mut().open_field_props("Group2", 0); + h.run_steps(2); + h.state_mut().field_props.as_mut().expect("open").name = "Group1".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(names(&h), ["Group1"]); + assert_eq!(h.state().views[0].prepare.selected, Some(("Group1".to_string(), 1)), "the moved button"); + h.key_press(egui::Key::ArrowRight); + h.run_steps(2); + let nudged = rects(&h); + assert_eq!(nudged[0], start[0], "the group's own button stays"); + assert!((nudged[1][0] - start[1][0] - 1.0).abs() < 0.01, "{:?} → {:?}", start[1], nudged[1]); + h.state_mut().execute("edit.undo"); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(names(&h), ["Group1", "Group2"]); + assert_eq!(rects(&h), start); + h.state_mut().execute("edit.redo"); + h.state_mut().execute("edit.redo"); + h.run_steps(2); + assert_eq!(names(&h), ["Group1"]); + assert_eq!(rects(&h), nudged); +} + #[test] fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { use egui_kittest::kittest::NodeT; From 58c50f60d64abf9cbdeebc193eb465213b74d739 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 12:32:57 -0400 Subject: [PATCH 046/120] M6: a list box's top row is chosen after the edit's new font size set_props chose /TI inside the field-dictionary update, from the field read before the edit, so a font size or look changed in the same call was not counted: at a larger size fewer rows fit and the selection could end up below the visible rows. The field and widget top rows are now chosen after every layout change, from the field read again, before the redraw. Test: a_list_boxs_top_row_follows_a_font_size_changed_with_its_items (fails with the old ordering: rows 1..2 shown, selection on row 3). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 25 +++++++++++++------------ crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 9e810c6709..01780ce6b5 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -235,15 +235,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(), + ]); + let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec(); + set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + let g = field(&fields(&doc), "pick").clone(); + let w = &g.widgets[0]; + let rows = w.list.unwrap(); + let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]); + assert!(shown.contains(&3), "C is row 3; rows {shown:?} show"); +} + #[test] fn sorting_a_combo_boxs_items_repoints_its_selected_index() { let mut doc = one_page(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dae9ece479..4be45105b6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4991,7 +4991,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items"] +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items"] notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] From 37bf4ee986ca66e946fe1e1df7bf0a616ac6b5f3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:39:14 +0100 Subject: [PATCH 047/120] perf(engine): export page images on worker threads Export > Image spent its time in the PNG, JPEG and TIFF encoders, one page after another: on a 55-page manual at 150 dpi, drawing every page took about 0.34 s and encoding it 1.1 s (PNG) to 2.0 s (JPEG). Exporter::images exports a list of pages, drawing and encoding several at once on worker threads, each with its own PageRenderer (as automation's extract_parallel does), and hands the files to a sink in page order on the calling thread. Workers draw at most one page ahead of the sink and fewer run when 1 GiB can't hold that many of the largest page. The first page or sink error stops the export with every earlier page handed over, as before. A page panic becomes that page's error, and a panicking sink stops the workers. One page, one core or wasm keeps the per-page loop. Export > Image and the doc_export_images tool use it. Files are byte-identical. One core is left for the interface (at most 8 workers). With examples/export_bench, on a Ryzen with 16 threads (shared), mean of two medians of 21 runs: PNG 150 dpi 1874 -> 340 ms (-82%) JPEG 150 dpi 2106 -> 340 ms (-84%) TIFF 150 dpi 2032 -> 375 ms (-82%) PNG 300 dpi 3804 -> 590 ms (-84%) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/documents.rs | 18 +- crates/engine/examples/export_bench.rs | 61 +++++++ crates/engine/src/export.rs | 190 +++++++++++++++++++++- crates/engine/src/tests.rs | 64 ++++++++ crates/ui-egui/src/workflows/export_ui.rs | 18 +- 5 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 crates/engine/examples/export_bench.rs diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 26addfd80b..3ce312f395 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -539,11 +539,23 @@ impl Automation { }; let mut files = Vec::new(); let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; + // A write error stops the export as it is, not as a page error. + let mut write_error = None; + let exported = ex.images(&pages, dpi, format, |p, img| { let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; + write_atomic(&path, &img).map_err(|e| { + let message = e.to_string(); + write_error = Some(e); + message + })?; files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Some(e) = write_error { + return Err(e); + } + exported.map_err(failed)?; + for &p in &pages { // A page too large for the renderer at `dpi` is drawn at the most it allows. let used = ex.dpi_used(p, dpi); if used < dpi.clamp(18.0, 1200.0) - 0.5 { diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 0000000000..41520d2ffa --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,61 @@ +//! Times Export ▸ Image over every page of a PDF, for before and after comparisons. +//! +//! Usage: `export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]` (defaults: 150 dpi, PNG, 7 runs). Each +//! run exports every page through [`Exporter::images`] from a fresh exporter, as Export ▸ Image +//! does, and prints its wall time; the summary gives the median and the minimum, the total size +//! and an FNV-1a hash of every file in page order. Equal hashes mean equal output. Build with +//! `--release`; timings are diagnostic, never a CI deadline. + +use std::error::Error; +use std::sync::Arc; +use std::time::Instant; + +use pdfcraft_engine::Session; +use pdfcraft_engine::export::{Exporter, ImageFormat}; + +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let path = args.next().ok_or("usage: export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]")?; + let dpi: f64 = args.next().map_or(Ok(150.0), |s| s.parse())?; + let format = match args.next().as_deref() { + None | Some("png") => ImageFormat::Png, + Some("jpeg" | "jpg") => ImageFormat::Jpeg { quality: 85 }, + Some("tiff" | "tif") => ImageFormat::Tiff, + Some(other) => return Err(format!("unknown format {other:?}").into()), + }; + let runs: usize = args.next().map_or(Ok(7), |s| s.parse())?; + let mut session = Session::new(); + let id = session.open("bench.pdf", None, Arc::new(std::fs::read(&path)?), None)?; + let src = session.get(id).ok_or("the document did not open")?.export_source(); + let pages: Vec = (0..src.pages).collect(); + let mut times = Vec::new(); + let mut summary = None; + for run in 0..runs { + let start = Instant::now(); + let mut exporter = Exporter::from_source(src.clone()); + let (mut hash, mut bytes) = (FNV_OFFSET, 0usize); + exporter.images(&pages, dpi, format, |_, file| { + bytes += file.len(); + for b in file { + hash = (hash ^ u64::from(b)).wrapping_mul(FNV_PRIME); + } + Ok(()) + })?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + println!("run {}: {ms:.1} ms", run + 1); + times.push(ms); + summary = Some((bytes, hash)); + } + times.sort_by(f64::total_cmp); + if let (Some(median), Some(min), Some((bytes, hash))) = (times.get(times.len() / 2), times.first(), summary) { + println!( + "{} pages, {} at {dpi} dpi: median {median:.1} ms, min {min:.1} ms ({runs} runs), {bytes} bytes, hash {hash:016x}", + pages.len(), + format.label() + ); + } + Ok(()) +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 01159df800..1330faf272 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -4,13 +4,52 @@ //! text, from a //! document's working file (so unsaved edits and hidden layers are respected, as on screen). +use std::collections::BTreeMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex, PoisonError, mpsc}; + use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use crate::Document; +/// At most this many pages are drawn at once by [`Exporter::images`]. +const MAX_IMAGE_WORKERS: usize = 8; +/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi. +/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run +/// on fewer workers; a page over the cap still exports, on one worker, as it did before. +const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0; + +/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi. +fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result, String> { + let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + encode_image(r.width, r.height, &r.rgba, format, used) +} + +/// Tells the workers of [`Exporter::images`] to stop when dropped. +struct StopWorkers<'a> { + stop: &'a AtomicBool, + handed_over: &'a (Mutex, Condvar), +} + +impl Drop for StopWorkers<'_> { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + // Taken under the lock, so a worker between its check and its wait still sees it. + drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner)); + self.handed_over.1.notify_all(); + } +} + /// Renders and extracts pages of one document state. pub struct Exporter { renderer: PageRenderer, + /// What the renderer draws, for the extra renderers [`Exporter::images`] starts. + bytes: std::sync::Arc>, + config: pdfcraft_render::RenderConfig, pages: usize, /// Each page's displayed size in points, for [`Exporter::dpi_used`]. sizes: Vec<(f32, f32)>, @@ -65,7 +104,13 @@ impl Exporter { /// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what. /// A source whose `config.reject_oversize` is set refuses such pages instead. pub fn from_source(src: ExportSource) -> Self { - Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes } + Self { + renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()), + bytes: src.bytes, + config: src.config, + pages: src.pages, + sizes: src.sizes, + } } /// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked @@ -96,11 +141,146 @@ impl Exporter { /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; - let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); - if let Some(e) = r.error { - return Err(format!("page {}: {e}", page + 1)); + let used = self.dpi_used(page, dpi); + draw_image(&mut self.renderer, page, dpi, format, used) + } + + /// Pages `pages` as image files of `format`, handed to `sink` in order with their page + /// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs + /// several times what drawing it does, so several pages are drawn and encoded at once on + /// worker threads, each with its own renderer, while `sink` runs on the calling thread. + /// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are + /// large, so memory stays bounded. Stops at the first page that fails or the first error `sink` + /// returns, and returns it; every page before it has been handed to `sink`. + pub fn images( + &mut self, + pages: &[usize], + dpi: f64, + format: ImageFormat, + sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + let workers = self.image_workers(pages, dpi); + self.images_on(workers, pages, dpi, format, sink) + } + + /// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread). + pub(crate) fn images_on( + &mut self, + workers: usize, + pages: &[usize], + dpi: f64, + format: ImageFormat, + mut sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + if workers <= 1 { + for &p in pages { + let img = self.image(p, dpi, format)?; + sink(p, img)?; + } + return Ok(()); } - encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) + let used: Vec = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect(); + let count = self.pages; + let (bytes, config) = (&self.bytes, &self.config); + let next = AtomicUsize::new(0); + let stop = AtomicBool::new(false); + // How many pages `sink` has taken; a worker waits while its page is `workers` past it. + let handed_over = (Mutex::new(0usize), Condvar::new()); + let (tx, rx) = mpsc::channel::<(usize, Result, String>)>(); + let mut first = Some(&mut self.renderer); + std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + let tx = tx.clone(); + let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over); + s.spawn(move || { + let mut own = None; + let renderer = match first { + Some(r) => r, + None => own.insert(PageRenderer::new(bytes.clone(), config.clone())), + }; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break }; + let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner); + while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) { + taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner); + } + drop(taken); + if stop.load(Ordering::Relaxed) { + break; + } + let img = if page < count { + catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used))) + .unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1))) + } else { + Err(format!("page {} does not exist", page + 1)) + }; + if tx.send((i, img)).is_err() { + break; + } + } + }) + }) + .collect(); + drop(tx); + // Stops the workers however this ends, a panicking `sink` included: waiting ones + // would otherwise keep the scope from returning. + let stopper = StopWorkers { stop: &stop, handed_over: &handed_over }; + let mut ready = BTreeMap::new(); + let mut result = Ok(()); + let mut taken = 0; + while let Some(&page) = pages.get(taken) { + let Some(img) = ready.remove(&taken) else { + match rx.recv() { + Ok((i, img)) => { + ready.insert(i, img); + } + Err(_) => { + result = Err(format!("page {}: the export stopped unexpectedly", page + 1)); + break; + } + } + continue; + }; + if let Err(e) = img.and_then(|img| sink(page, img)) { + result = Err(e); + break; + } + taken += 1; + *handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken; + handed_over.1.notify_all(); + } + drop(stopper); + // Joined here, so a worker that panicked outside a page is not re-raised by the scope. + for h in handles { + let _ = h.join(); + } + result + }) + } + + /// How many pages [`Exporter::images`] draws at once: one per core but one, up to + /// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one + /// when there is nothing to share or no threads (wasm). + fn image_workers(&self, pages: &[usize], dpi: f64) -> usize { + if cfg!(target_arch = "wasm32") || pages.len() < 2 { + return 1; + } + // One core is left for the interface, which keeps drawing while an export runs. + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS); + // The raster (RGBA), the RGB copy the encoders take, and room for the encoded file. + let largest = pages + .iter() + .filter_map(|p| { + let (w, h) = *self.sizes.get(*p)?; + let k = self.dpi_used(*p, dpi) / 72.0; + Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0) + }) + .fold(0.0, f64::max); + // A float-to-integer cast saturates (and NaN is 0). + let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores }; + cores.min(by_memory).min(pages.len()).max(1) } /// The reading-order text of a page. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c622af21b4..e7e68a7588 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3410,6 +3410,70 @@ fn pages_export_as_png_jpeg_and_tiff() { assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); } +/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone +/// writes, handed over in the order asked for, whatever the number of pages drawn at once. +#[test] +fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(12); + let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4]; + for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] { + let alone: Vec<(usize, Vec)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect(); + for workers in [1, 2, 4, 8] { + let mut got = Vec::new(); + let mut ex = Exporter::new(s.get(id).unwrap()); + ex.images_on(workers, &pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{} with {workers} workers", format.label()); + } + let mut got = Vec::new(); + Exporter::new(s.get(id).unwrap()) + .images(&pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{}", format.label()); + } +} + +/// A page that can't be exported, or a file that can't be written, stops the export there with +/// its error: every page before it is handed over, none after it. +#[test] +fn exporting_pages_at_once_stops_at_the_first_error() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(6); + for workers in [1, 4] { + let mut ex = Exporter::new(s.get(id).unwrap()); + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + Ok(()) + }); + assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1], "{workers} workers"); + + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + if p == 2 { Err("disk full".to_string()) } else { Ok(()) } + }); + assert_eq!(result, Err("disk full".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1, 2], "{workers} workers"); + // The exporter is still usable afterwards. + assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok()); + } + // A panicking sink stops the workers rather than leaving them waiting for it. + let mut ex = Exporter::new(s.get(id).unwrap()); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) }) + })); + assert!(panicked.is_err()); +} + /// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { use tiff::{decoder::ifd::Value, tags::Tag}; diff --git a/crates/ui-egui/src/workflows/export_ui.rs b/crates/ui-egui/src/workflows/export_ui.rs index fae075e30b..bcda63c57b 100644 --- a/crates/ui-egui/src/workflows/export_ui.rs +++ b/crates/ui-egui/src/workflows/export_ui.rs @@ -176,12 +176,18 @@ fn run( // Handled above. ExportKind::AllImages => String::new(), ExportKind::Image => { - for (k, p) in pages.iter().enumerate() { - set(k, None); - let result = ex.image(*p, dpi, format).and_then(|img| sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)); - if let Err(e) = result { - return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); - } + set(0, None); + // Pages are drawn several at once and handed over in order: the progress counts the + // pages written so far. + let mut done = 0; + let result = ex.images(&pages, dpi, format, |p, img| { + sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)?; + done += 1; + set(done, None); + Ok(()) + }); + if let Err(e) = result { + return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); } let mut msg = if total == 1 { crate::i18n::fmt(tl!("Exported 1 image"), &[]) From b8871e872edf9396925a3d539d86b48bcfdb7c3c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 13:14:53 -0400 Subject: [PATCH 048/120] M6: a joining button that was its own field keeps only its widget triggers When the source radio field is also its widget, the join removed the field-only keys but kept its whole /AA, so field triggers (K, F, V, C) stayed on a dictionary that is now only a widget. Split it as split_merged does for the target: keep the widget triggers, drop the field's, and remove /AA when nothing is left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/join.rs | 14 ++++++++--- crates/forms/src/author/lifecycle.rs | 2 +- crates/forms/src/tests.rs | 37 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index 0e74b94be7..d6fe51e49c 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -4,12 +4,12 @@ use std::collections::{HashMap, HashSet}; -use super::lifecycle::{drop_from_calculation_order, split_merged}; +use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; use super::tree::{detach, list_of}; use super::*; -/// The keys a field that is also its own widget sheds when it becomes only a widget. `/AA` -/// stays: a widget's own triggers live there. +/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` +/// keeps only the widget's own triggers. const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; /// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). @@ -74,6 +74,14 @@ pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> for k in FIELD_ONLY_KEYS { d.remove(k); } + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + match aa.as_ref().map(split_triggers) { + Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)), + Some((None, _)) => { + d.remove(b"AA"); + } + None => {} + } } let mut state = w.on_state.clone(); if let Some(old) = w.on_state.as_deref() diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index d23d28d95a..3a00d1a695 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -245,7 +245,7 @@ const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO /// Split additional-actions dictionary `aa` into the widget's triggers and the field's. /// An empty half is `None`. -fn split_triggers(aa: &Dict) -> (Option, Option) { +pub(super) fn split_triggers(aa: &Dict) -> (Option, Option) { let (mut widget, mut field) = (Dict::new(), Dict::new()); for (k, v) in aa.iter() { let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field }; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4bda036c48..44539a2e34 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4772,6 +4772,43 @@ fn a_joined_merged_groups_button_keeps_its_actions() { assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V")); } +/// A joining button that was its own field keeps its widget triggers and drops the field's. +#[test] +fn a_joining_merged_button_sheds_its_fields_triggers() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&doc), "other").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"U".to_vec(), js("up();")); + aa.set(b"V".to_vec(), js("validate();")); + aa.set(b"C".to_vec(), js("calc();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + let group = field(&fields(&doc), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = doc.get(moved.obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays"); + let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&once), "other").obj; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"V".to_vec(), js("validate();")); + once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + rename_to(&mut once, "other", "solo").unwrap(); + let once = reopen(&once); + let group = field(&fields(&once), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = once.get(moved.obj).as_dict().unwrap().clone(); + assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA"); +} + /// However many "ChoiceN" names a group already uses, a joining button with a clashing export /// value gets a free one, so the two buttons can still be chosen apart. #[test] From 6a76d280168ebad42198de184d03ac11bf31f647 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 13:22:02 -0400 Subject: [PATCH 049/120] fix(render): a shading in a knockout group replaces only what is under its shape Review of #4898 (CodeRabbit): a shading clearing in another blend-mode pass cleared its whole drawing path, so a small mesh triangle erased the earlier elements of its pass around it. The same happened in the shading's own pass, and in any knockout group, because a knockout paint is copied over the path and a mesh texture is transparent outside its triangles. A shading in a knockout group now goes through knockout_shading: the shading at full opacity, composited with Compose::DestOut, clears its own shape (mesh triangles, /BBox), then in its own pass it is painted with SrcOver. A shading that paints nothing still clears nothing. The clear uses the shading without the fill opacity it carries, so a half-transparent shading still replaces all of what is under it. Test: it::knockout_backdrop::a_small_mesh_replaces_only_what_is_under_its_triangle (own pass, another pass, isolated group). Revert-proven per half; keeping the opacity in the clear fails raster::tests::knockout_groups_replace_what_their_elements_cover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/knockout_backdrop.rs | 25 ++++++- vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 82 ++++++++++++++++----- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/crates/render/tests/it/knockout_backdrop.rs b/crates/render/tests/it/knockout_backdrop.rs index 2934068f2d..1b647b6169 100644 --- a/crates/render/tests/it/knockout_backdrop.rs +++ b/crates/render/tests/it/knockout_backdrop.rs @@ -37,7 +37,8 @@ fn stream(dict: &str, content: &str) -> String { /// holding a non-isolated knockout group with a white Difference square from 80 to 120, and /// `/Img`, a white 1 x 1 image, `/Img2`, a blue 2 x 1 image whose soft mask is opaque on the /// left and transparent on the right, `/St`, a 2 x 1 stencil mask painting its left pixel, and -/// the shading `/Sh0`, a mesh whose one triangle lies off the page. +/// the shading `/Sh0`, a mesh whose one triangle lies off the page, and `/Sh1`, a red mesh +/// triangle from (90, 90) to (110, 90) and (100, 110). /// Returns a lookup of the colour at PDF point (x, y). fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>"; @@ -48,7 +49,7 @@ fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { .to_string(), stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"), stream( - &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R >> >>"), + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R /Sh1 14 0 R >> >>"), content, ), stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"), @@ -68,6 +69,10 @@ fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { "/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [300 310 300 310 0 1 0 1 0 1] /Filter /ASCIIHexDecode", "000000FF0000 00FF00FF0000 0000FFFF0000>", ), + stream( + "/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", + "005A5AFF0000 006E5AFF0000 00646EFF0000>", + ), ]; let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default()); let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); @@ -167,3 +172,19 @@ fn a_shading_that_paints_nothing_clears_nothing_in_another_pass() { let mesh = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /Sh0 sh"); check(&mesh, 100, 100, [0, 0, 255], "blue under an off-page Difference mesh"); } + +/// A shading replaces only what is under the part it paints: a small mesh triangle over a +/// square leaves the rest of the square, in its own pass, in another pass and in an isolated +/// knockout group. +#[test] +fn a_small_mesh_replaces_only_what_is_under_its_triangle() { + let square = "0 0 1 rg 20 20 160 160 re f"; + for (group, mode) in [(NON_ISOLATED, ""), (NON_ISOLATED, "/D gs"), ("/K true", "")] { + let content = format!("{square} {mode} /Sh1 sh"); + let mesh = render(group, &content); + check(&mesh, 40, 40, [0, 0, 255], &format!("{group} {mode}: square beside the triangle")); + if mode.is_empty() { + check(&mesh, 100, 97, [255, 0, 0], &format!("{group}: triangle")); + } + } +} diff --git a/vendor/README.md b/vendor/README.md index 5efa0ca2e3..128f7abf28 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (67) a non-isolated knockout group entered at full opacity, without a soft mask and with Normal blending is drawn once per blend mode its elements use (`Device::begin_knockout_pass`, `end_knockout_pass`), each pass a layer composited onto the backdrop in that mode, so each element blends with the group's backdrop in its own blend mode (ISO 32000-2 §11.4.7, §11.4.8; #4886): drawn as one layer starting transparent, white with `/BM /Difference` over magenta stayed white; each pass starts from the same marked-content visibility; inside an element's knockout erase pass a nested group takes one pass, leaving the outer group's passes alone; a masked image or stencil of another mode clears nothing under it, as in (39) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (67) `it::knockout_backdrop` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (26) the renderer side of hayro-interpret (67): in a blend-mode pass over a knockout group, an element of another blend mode clears what is under its shape instead of painting (a path, glyph or shading through `set_paint`, where a mesh that paints nothing clears nothing; an image or nested group's layer composites with `Compose::DestOut`, clearing its coverage, a masked image's or stencil's mask included), and the pass reports the other modes its elements used | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (26) `it::knockout_backdrop` (`cargo test -p pdfcraft-render --test it`) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (26) the renderer side of hayro-interpret (67): in a blend-mode pass over a knockout group, an element of another blend mode clears what is under its shape instead of painting (a path or glyph through `set_paint`; a shading in any knockout group, in its own pass or another, through `knockout_shading`: it clears its own shape at full opacity with `Compose::DestOut`, then paints in its own pass, so a mesh replaces only what is under its triangles, and nothing when it paints nothing; an image or nested group's layer composites with `Compose::DestOut`, clearing its coverage, a masked image's or stencil's mask included), and the pass reports the other modes its elements used | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (26) `it::knockout_backdrop` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 887493997a..6e80d549bf 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1157,26 +1157,54 @@ impl Renderer { let other_pass = !self.erasing && element_mode.is_some_and(|mode| self.knockout_pass_skips(mode)); if self.erasing || other_pass { - // A shading's `/BBox` clips what it paints, so it clips what it clears too, and a - // mesh that paints nothing (below) clears nothing. - let clip = match paint { - Paint::Pattern(p) => match p.as_ref() { - Pattern::Shading(s) => { - let bbox = self.shading_bbox(path, is_stroke); - let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0))); - if mesh_area(bbox, &encoded).is_none() { - return PaintSetup::Nothing; - } - s.shading.clip_path.clone() - } - _ => None, - }, - _ => None, - }; + if is_shading(paint) { + self.knockout_shading(paint, path, is_stroke, false); + return PaintSetup::Nothing; + } self.ctx.set_blend_mode(knockout_blend_mode()); self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); - return PaintSetup::Clip(clip); + return PaintSetup::Clip(None); } + if self.in_knockout() && is_shading(paint) { + self.knockout_shading(paint, path, is_stroke, true); + return PaintSetup::Nothing; + } + self.paint_setup(paint, path, is_stroke) + } + + /// PdfCraft patch (26): a shading in a knockout group replaces what is under it where it + /// paints, which a mesh's triangles or the shading's `/BBox` may make less than the path, + /// so it can't be copied over the path like other paints. The shading at full opacity, + /// composited with `Compose::DestOut`, clears its shape; then, when `paint_it`, the shading + /// is painted over the cleared area. A shading that paints nothing clears nothing. The + /// caller paints nothing more. + fn knockout_shading(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool, paint_it: bool) { + let PaintSetup::Clip(clip) = self.paint_setup(&shape_of(paint), path, is_stroke) else { return }; + if let Some(clip) = clip.as_ref() { + self.push_clip_path_inner(clip, FillRule::NonZero); + } + self.ctx.set_blend_mode(peniko::BlendMode::new(Mix::Normal, Compose::DestOut)); + self.draw_shape(path, is_stroke); + if paint_it && let PaintSetup::Clip(_) = self.paint_setup(paint, path, is_stroke) { + self.ctx.set_blend_mode(peniko::BlendMode::new(Mix::Normal, Compose::SrcOver)); + self.draw_shape(path, is_stroke); + } + if clip.is_some() { + self.ctx.pop_clip_path(); + } + self.ctx.set_blend_mode(knockout_blend_mode()); + } + + fn draw_shape(&mut self, path: &BezPath, is_stroke: bool) { + if is_stroke { + self.ctx.stroke_path(path); + } else { + self.ctx.fill_path(path); + } + } + + /// The paint `paint` draws on `path` with, set on the context (see [`PaintSetup`]). + fn paint_setup(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; // PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture @@ -2211,6 +2239,26 @@ struct KnockoutPass { other_modes: Vec, } +/// PdfCraft patch (26): `paint` at full opacity, for the shape it covers. +fn shape_of<'a>(paint: &Paint<'a>) -> Paint<'a> { + match paint { + Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(s) => { + let mut s = s.clone(); + s.opacity = 1.0; + Paint::Pattern(Box::new(Pattern::Shading(s))) + } + Pattern::Tiling(_) => paint.clone(), + }, + Paint::Color(_) => paint.clone(), + } +} + +/// PdfCraft patch (26): whether `paint` is a shading. +fn is_shading(paint: &Paint<'_>) -> bool { + matches!(paint, Paint::Pattern(p) if matches!(p.as_ref(), Pattern::Shading(_))) +} + /// PdfCraft patch (22): how an element of a knockout group is drawn (see `set_knockout`). fn knockout_blend_mode() -> peniko::BlendMode { peniko::BlendMode::new(Mix::Normal, Compose::Copy) From da8cea2b0b9ef2f45e26e8242e0269e2fff3d37c Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:20:54 -0500 Subject: [PATCH 050/120] M10: reuse verified OCR model and packaging prerequisites --- ATTRIBUTION.md | 4 +- ATTRIBUTION.toml | 16 ++++---- Cargo.lock | 16 ++++++-- Cargo.toml | 8 ++-- ROADMAP.md | 3 ++ crates/ocr/Cargo.toml | 5 ++- crates/ocr/src/lib.rs | 70 +++++++++++++++++++++++++++++++--- docs/releasing.md | 21 ++++++---- docs/roadmap.md | 4 +- packaging/windows/test-msi.ps1 | 9 +++-- parity/acrobat-features.toml | 4 +- 11 files changed, 123 insertions(+), 37 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fa3efb62a7..23ebcbbe82 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | -| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | -| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | +| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | +| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | ## Optional build inputs (1) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index bfaf948862..489247559d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" [[fetched]] -file = "text-detection.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" +file = "text-detection.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)" -sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca" +sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe" [[fetched]] -file = "text-recognition.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten" +file = "text-recognition.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" -sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" +sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" [[build_input]] name = "craft-fonts" diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..99239644d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1174,7 +1174,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [ "egui", "serde", @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [ "craft-keymap", "craft-quit", @@ -1210,7 +1210,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [ "dispatch2", "egui", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [ "egui", "log", @@ -4340,6 +4340,7 @@ dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] @@ -5473,6 +5474,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5522,6 +5524,12 @@ dependencies = [ "rten-base", ] +[[package]] +name = "rten-onnx" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e" + [[package]] name = "rten-parallel" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index 8eb38b29af..e6b5bdcf8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,12 +13,12 @@ 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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", 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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" } # 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 = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", 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 = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..f5de8044e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -199,6 +200,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. @@ -682,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/docs/releasing.md b/docs/releasing.md index 600cc34cac..6c7131fdd1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin deliberately. -**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call -`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to -fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text -and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where -`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip), -`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` -(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet. +**OCR models:** desktop packaging stages the Scan & OCR models (#103). +The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256. +The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`. +Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network. +Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models. + +Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair. +An incomplete ONNX update can therefore use an intact RTEN pair in the same directory. +Discovery never combines model formats or files from different directories. +Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`. +The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair. + +The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). +The web build does not include these models, and OCR remains limited to the Latin alphabet without accents. ### macOS diff --git a/docs/roadmap.md b/docs/roadmap.md index 40fa711c24..25205e92c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -193,7 +194,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -254,6 +255,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 24a4c6d75a..5a9130b29f 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -288,13 +288,16 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent' # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name. if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" } -$modelFiles = 0 +$modelFiles = @() $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''') try { [void] $view.Execute() - while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } } + # FileName can contain a generated 8.3 name before the long name: SHORT|long. + while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] } } finally { [void] $view.Close() } -if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" } +foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) { + Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\" +} $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1 Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bcba7a0ecd..47ac06fdd4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6544,8 +6544,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] -notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." +evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] +notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] id = "ocr.recognize-range" From bf353cb0d9a38084ff461ebeca33546ee064f151 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:37 +0000 Subject: [PATCH 051/120] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc648..cc9416e9c9 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 9e90b2a54d6250465ba3d11a6eb0b13f3b690381 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:22:20 -0400 Subject: [PATCH 052/120] refactor(ui): use craft-ui's ComboBox for every dropdown (#4988) * Use craft_ui::ComboBox so dropdown menus grow back (egui#5225) * chore(deps): craft-libs 60d60d48 (dropdown fixes) * chore(deps): craft-libs 6af63481 (dropdown fixes) * chore(deps): craft-libs 4d7b82f7 (dropdown fixes) * chore(deps): craft-libs 92d392cf (#387 merged) --- Cargo.lock | 211 ++++++++++++++++-- Cargo.toml | 14 +- clippy.toml | 4 + crates/ui-egui/src/dialogs.rs | 16 +- crates/ui-egui/src/dialogs/appearance.rs | 2 +- crates/ui-egui/src/dialogs/comment_props.rs | 4 +- crates/ui-egui/src/dialogs/credits.rs | 2 +- crates/ui-egui/src/dialogs/marks_ui.rs | 8 +- crates/ui-egui/src/dialogs/pageboxes.rs | 4 +- crates/ui-egui/src/dialogs/protect.rs | 6 +- crates/ui-egui/src/dialogs/standards_ui.rs | 2 +- crates/ui-egui/src/panels/format_text_ui.rs | 8 +- crates/ui-egui/src/tools/bulk_fields.rs | 10 +- crates/ui-egui/src/tools/link_ui.rs | 8 +- crates/ui-egui/src/tools/measure_ui.rs | 4 +- .../ui-egui/src/tools/prepare/properties.rs | 14 +- crates/ui-egui/src/tools/prepare/tabs.rs | 22 +- crates/ui-egui/src/tools/redact_ui.rs | 6 +- crates/ui-egui/src/tools/sign_ui/dialog.rs | 4 +- crates/ui-egui/src/tools/stamps_ui.rs | 2 +- crates/ui-egui/src/workflows/a11y_ui.rs | 2 +- crates/ui-egui/src/workflows/actions_ui.rs | 13 +- crates/ui-egui/src/workflows/compare_ui.rs | 2 +- crates/ui-egui/src/workflows/export_ui.rs | 6 +- crates/ui-egui/src/workflows/js_ui.rs | 10 +- crates/ui-egui/src/workflows/ocr_ui.rs | 6 +- crates/ui-egui/src/workflows/optimize_ui.rs | 8 +- crates/ui-egui/src/workflows/print_ui/body.rs | 8 +- 28 files changed, 298 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99239644d5..b201da8413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1023,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1169,12 +1178,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -1184,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-keymap", "craft-quit", @@ -1197,11 +1234,11 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1210,7 +1247,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "dispatch2", "egui", @@ -1221,20 +1258,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -2073,6 +2118,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2188,7 +2244,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2882,6 +2938,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2890,11 +2963,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2907,6 +3001,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2917,6 +3012,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2946,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2953,6 +3077,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -3251,6 +3399,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -4392,7 +4543,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -4555,7 +4706,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", + "roxmltree 0.20.0", "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4568,7 +4719,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", "thiserror 2.0.21", ] @@ -4889,12 +5040,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5445,6 +5608,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -6523,7 +6695,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -6718,6 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -7086,7 +7262,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -8060,6 +8236,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -8384,8 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8394,6 +8576,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index e6b5bdcf8b..99f9fa251b 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 = "acab34c0d8d84363ea3d05885f1a15601c233a19", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "acab34c0d8d84363ea3d05885f1a15601c233a19" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # 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 = "acab34c0d8d84363ea3d05885f1a15601c233a19" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", default-features = false } +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 } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +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"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } diff --git a/clippy.toml b/clippy.toml index 7f2fd53057..e908d13581 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,3 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d0e9482d06..a0c1ca880e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -225,7 +225,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); let label = ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { - egui::ComboBox::from_id_salt("iv-nav") + craft_ui::ComboBox::from_id_salt("iv-nav") .selected_text(tl!(format!("{:?}", v.navigation) .replace("PageOnly", "Page Only") .replace("Pages", "Pages Panel and Page") @@ -257,7 +257,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - egui::ComboBox::from_id_salt("iv-layout") + craft_ui::ComboBox::from_id_salt("iv-layout") .selected_text(shown) .show_ui(ui, |ui| { for (l, n) in names { @@ -283,7 +283,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { M::Percent(p) => format!("{p:.0}%"), m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; - egui::ComboBox::from_id_salt("iv-mag") + craft_ui::ComboBox::from_id_salt("iv-mag") .selected_text(shown) .show_ui(ui, |ui| { for (m, n) in names { @@ -798,7 +798,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(8.0); egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Direction:")); - egui::ComboBox::from_id_salt("rotate-dir") + craft_ui::ComboBox::from_id_salt("rotate-dir") .selected_text(match d.degrees { 270 => tl!("Counterclockwise 90 degrees"), 180 => tl!("180 degrees"), @@ -824,7 +824,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); ui.label(tl!("Rotate:")); - egui::ComboBox::from_id_salt("rotate-parity") + craft_ui::ComboBox::from_id_salt("rotate-parity") .selected_text(match d.parity { P::Both => tl!("Even and Odd Pages"), P::Even => tl!("Even Pages Only"), @@ -837,7 +837,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); ui.label(""); - egui::ComboBox::from_id_salt("rotate-orient") + craft_ui::ComboBox::from_id_salt("rotate-orient") .selected_text(match d.orientation { O::Both => tl!("Landscape and Portrait Pages"), O::Landscape => tl!("Landscape Pages"), @@ -946,7 +946,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.horizontal(|ui| { ui.label(tl!("Sort comments by:")); - egui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { for s in pdfcraft_engine::SummarySort::ALL { ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); } @@ -1222,7 +1222,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::None, tl!("None (prefix only)")), ]; let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); - egui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { for (s, l) in styles { ui.selectable_value(&mut d.style, s, l); } diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 1393c9d937..48ad0cb31c 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -119,7 +119,7 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { let label = ui.label(tl!("Appearance mode")); - let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { app.execute(mode_command(preference)); diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 38fd2c4117..4702e63f9e 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -207,7 +207,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo egui::Grid::new("comment-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { if let Some(icon) = e.icon.as_mut() { ui.label(tl!("Icon")); - egui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { for i in ICONS { ui.selectable_value(icon, i, i.name()); } @@ -247,7 +247,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.label(tl!("Line ending")); ui.horizontal(|ui| { for (i, ending) in ends.iter_mut().enumerate() { - egui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending_label(*ending)).width(188.0).show_ui( + craft_ui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending_label(*ending)).width(188.0).show_ui( ui, |ui| { for style in LineEnding::ALL { diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 4fe58a6714..d509a3b0eb 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -243,7 +243,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::t("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 5340cef393..437d45e9fe 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -63,7 +63,7 @@ impl PageRange { Subset::Even => tl!("Even pages only"), Subset::Odd => tl!("Odd pages only"), }; - egui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| { ui.selectable_value(&mut self.subset, Subset::All, tl!("All pages in range")); ui.selectable_value(&mut self.subset, Subset::Even, tl!("Even pages only")); ui.selectable_value(&mut self.subset, Subset::Odd, tl!("Odd pages only")); @@ -227,7 +227,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.horizontal(|ui| { // The standard faces a header can be drawn in (#3754). ui.label(tl!("Font:")); - egui::ComboBox::from_id_salt("hf-font").selected_text(d.hf.font.label()).width(100.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("hf-font").selected_text(d.hf.font.label()).width(100.0).show_ui(ui, |ui| { for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut d.hf.font, f, f.label()); } @@ -267,7 +267,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } }); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("hf-page-format").selected_text(PAGE_FORMATS[d.page_format].trim_matches(['<', '>'])).show_ui( + craft_ui::ComboBox::from_id_salt("hf-page-format").selected_text(PAGE_FORMATS[d.page_format].trim_matches(['<', '>'])).show_ui( ui, |ui| { for (k, f) in PAGE_FORMATS.iter().enumerate() { @@ -279,7 +279,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M d.hf.text[d.focused_box].push_str(PAGE_FORMATS[d.page_format]); } ui.add_space(12.0); - egui::ComboBox::from_id_salt("hf-date-format").selected_text(DATE_FORMATS[d.date_format].trim_matches(['<', '>'])).show_ui( + craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(DATE_FORMATS[d.date_format].trim_matches(['<', '>'])).show_ui( ui, |ui| { for (k, f) in DATE_FORMATS.iter().enumerate() { diff --git a/crates/ui-egui/src/dialogs/pageboxes.rs b/crates/ui-egui/src/dialogs/pageboxes.rs index 1522d00034..57b5edc911 100644 --- a/crates/ui-egui/src/dialogs/pageboxes.rs +++ b/crates/ui-egui/src/dialogs/pageboxes.rs @@ -102,14 +102,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (PageBox::Media, "MediaBox"), ]; let cur = names.iter().find(|(b, _)| *b == d.which).map_or("CropBox", |(_, l)| *l); - egui::ComboBox::from_id_salt("boxes-which").selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("boxes-which").selected_text(cur).show_ui(ui, |ui| { for (b, l) in names { ui.selectable_value(&mut d.which, b, l); } }); ui.end_row(); ui.label(tl!("Units")); - egui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { for u in Unit::ALL { ui.selectable_value(&mut d.unit, u, u.label()); } diff --git a/crates/ui-egui/src/dialogs/protect.rs b/crates/ui-egui/src/dialogs/protect.rs index 959877de64..ffc8b53f8e 100644 --- a/crates/ui-egui/src/dialogs/protect.rs +++ b/crates/ui-egui/src/dialogs/protect.rs @@ -144,7 +144,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (A::Rc4_40, tl!("Acrobat 3.0 and later (40-bit RC4)")), ]; let cur = algos.iter().find(|(a, _)| *a == p.algorithm).map_or(algos[0].1, |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (a, l) in algos { ui.selectable_value(&mut p.algorithm, a, l); } @@ -164,7 +164,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let opts = [(Printing::None, tl!("None")), (Printing::Low, tl!("Low Resolution (150 dpi)")), (Printing::High, tl!("High Resolution"))]; let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or(tl!("High Resolution"), |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.printing, o, l); } @@ -179,7 +179,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (Changes::AnyExceptExtract, tl!("Any except extracting pages")), ]; let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or(tl!("None"), |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.changes, o, l); } diff --git a/crates/ui-egui/src/dialogs/standards_ui.rs b/crates/ui-egui/src/dialogs/standards_ui.rs index 5fc4b3d355..11c0d1fd50 100644 --- a/crates/ui-egui/src/dialogs/standards_ui.rs +++ b/crates/ui-egui/src/dialogs/standards_ui.rs @@ -72,7 +72,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let before = app.pdfa.level; ui.horizontal(|ui| { ui.label(tl!("Conformance level:")); - egui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| { for l in [Level::A2b, Level::A3b] { ui.selectable_value(&mut app.pdfa.level, l, l.label()); } diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 69a49704a2..6cf07ce05d 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -78,7 +78,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // The font, across the panel. let width = ui.available_width(); - egui::ComboBox::from_id_salt("format-font") + craft_ui::ComboBox::from_id_salt("format-font") .selected_text(fonts.shown.as_str()) .width(width) .show_ui(ui, |ui| { @@ -128,7 +128,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // Size and colour | bullets, numbering and alignment. row(ui, |ui| { - let _ = egui::ComboBox::from_id_salt("format-size") + let _ = craft_ui::ComboBox::from_id_salt("format-size") .selected_text(number(look.size)) .width(64.0) .show_ui(ui, |ui| { @@ -206,7 +206,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; - egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); for v in LINE_SPACINGS { ui.selectable_value(&mut e.line_spacing, v, number(v)); @@ -216,7 +216,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: ui.add_enabled_ui(false, |ui| { labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); - let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing") + let _ = craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") .selected_text("0") .width(half) .show_ui(ui, |_| {}) diff --git a/crates/ui-egui/src/tools/bulk_fields.rs b/crates/ui-egui/src/tools/bulk_fields.rs index fe401f0574..7acb2ff75b 100644 --- a/crates/ui-egui/src/tools/bulk_fields.rs +++ b/crates/ui-egui/src/tools/bulk_fields.rs @@ -238,7 +238,7 @@ impl crate::PdfCraftApp { fn bool_row(ui: &mut egui::Ui, label: &str, c: &mut Change) { row(ui, label, c, |ui, value| { - egui::ComboBox::from_id_salt(label).selected_text(tl!(if *value { "Yes" } else { "No" })).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(label).selected_text(tl!(if *value { "Yes" } else { "No" })).show_ui(ui, |ui| { ui.selectable_value(value, true, tl!("Yes")); ui.selectable_value(value, false, tl!("No")); }); @@ -296,7 +296,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool }); let font_label = if d.font.apply { d.font.value.label().to_string() } else { d.font_label.clone() }; row(ui, "Font:", &mut d.font, |ui, v| { - egui::ComboBox::from_id_salt("bulk-font").selected_text(tl!(&font_label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-font").selected_text(tl!(&font_label)).show_ui(ui, |ui| { for font in FieldFont::ALL { ui.selectable_value(v, font, font.label()); } @@ -314,7 +314,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool ui.add(egui::DragValue::new(v).range(0.0..=12.0).speed(0.25).suffix(" pt")); }); row(ui, "Line Style:", &mut d.style, |ui, v| { - egui::ComboBox::from_id_salt("bulk-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { for style in BorderStyle::ALL { ui.selectable_value(v, style, tl!(style.label())); } @@ -329,7 +329,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool 2 => "Right", _ => "Left", }; - egui::ComboBox::from_id_salt("bulk-align").selected_text(tl!(label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-align").selected_text(tl!(label)).show_ui(ui, |ui| { for (value, label) in [(0, "Left"), (1, "Center"), (2, "Right")] { ui.selectable_value(v, value, tl!(label)); } @@ -344,7 +344,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool } if d.check_fields { row(ui, "Button Style:", &mut d.check_style, |ui, v| { - egui::ComboBox::from_id_salt("bulk-check-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-check-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { for style in CheckStyle::ALL { ui.selectable_value(v, style, tl!(style.label())); } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index f7768912dd..f804f121e9 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -229,7 +229,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token widgets::section_title(ui, tl!("Link Appearance")); egui::Grid::new("link-appearance").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Link Type:")); - egui::ComboBox::from_id_salt("link-type") + craft_ui::ComboBox::from_id_salt("link-type") .selected_text(if d.style.visible { tl!("Visible Rectangle") } else { tl!("Invisible Rectangle") }) .show_ui(ui, |ui| { ui.selectable_value(&mut d.style.visible, true, tl!("Visible Rectangle")); @@ -237,7 +237,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token }); ui.end_row(); ui.label(tl!("Highlight Style:")); - egui::ComboBox::from_id_salt("link-hl").selected_text(tl!(d.style.highlight.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("link-hl").selected_text(tl!(d.style.highlight.label())).show_ui(ui, |ui| { for h in LinkHighlight::ALL { ui.selectable_value(&mut d.style.highlight, h, tl!(h.label())); } @@ -245,7 +245,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.end_row(); if d.style.visible { ui.label(tl!("Line Thickness:")); - egui::ComboBox::from_id_salt("link-w") + craft_ui::ComboBox::from_id_salt("link-w") .selected_text(if d.style.width <= 1.0 { tl!("Thin") } else if d.style.width <= 2.0 { @@ -267,7 +267,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token } else { 0 }; - egui::ComboBox::from_id_salt("link-s").selected_text(tl!(["Solid", "Dashed", "Underline"][style])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("link-s").selected_text(tl!(["Solid", "Dashed", "Underline"][style])).show_ui(ui, |ui| { for (k, l) in ["Solid", "Dashed", "Underline"].iter().enumerate() { ui.selectable_value(&mut style, k, tl!(l)); } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 79670ad304..2e9e600dfc 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -438,7 +438,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { None => state.error = Some(tl!("Enter a scale such as 1:50.").into()), } } - egui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { for n in RATIOS { if ui.selectable_label(state.ratio_text == format_ratio(n), format_ratio(n)).clicked() { chosen = Some(n); @@ -449,7 +449,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9)); let page_unit = page_unit_of(state.page_unit, &state.unit, units); - egui::ComboBox::from_id_salt("measure-page-unit").selected_text(page_unit.label()).width(44.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("measure-page-unit").selected_text(page_unit.label()).width(44.0).show_ui(ui, |ui| { for u in PageUnit::ALL { if ui.selectable_label(u == page_unit, u.label()).clicked() { state.page_unit = Some(u); diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index d37f20a724..8099d57489 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -17,7 +17,7 @@ fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: /// Shared PDF `/Q` picker for text and choice-field Options. fn alignment(ui: &mut egui::Ui, quadding: &mut i64) { ui.label(tl!("Alignment:")); - egui::ComboBox::from_id_salt("quadding") + craft_ui::ComboBox::from_id_salt("quadding") .selected_text(tl!(match (*quadding).clamp(0, 2) { 1 => "Center", 2 => "Right", @@ -87,7 +87,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.end_row(); if let Some(l) = d.look.as_mut() { ui.label(tl!("Font:")); - egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { for f in FieldFont::ALL { ui.selectable_value(&mut l.font, f, f.label()); } @@ -130,14 +130,14 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke tl!("Thick") } }; - egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { for (w, label) in [(1.0, tl!("Thin")), (2.0, tl!("Medium")), (3.0, tl!("Thick"))] { ui.selectable_value(&mut l.width, w, label); } }); ui.end_row(); ui.label(tl!("Line Style:")); - egui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { for s in BorderStyle::ALL { ui.selectable_value(&mut l.style, s, tl!(s.label())); } @@ -162,7 +162,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.horizontal(|ui| { let l = ui.label(tl!("Rotation:")); let before = d.rotation; - egui::ComboBox::from_id_salt("field-rotation") + craft_ui::ComboBox::from_id_salt("field-rotation") .selected_text(format!("{}°", d.rotation)) .show_ui(ui, |ui| { for r in [0, 90, 180, 270] { @@ -237,7 +237,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke if let Some(style) = d.check_style.as_mut() { ui.horizontal(|ui| { let l = ui.label(tl!(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" })); - egui::ComboBox::from_id_salt("check-style") + craft_ui::ComboBox::from_id_salt("check-style") .selected_text(tl!(style.label())) .show_ui(ui, |ui| { for s in pdfcraft_engine::CheckStyle::ALL { @@ -297,7 +297,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.horizontal(|ui| { ui.label(tl!("Default:")); let shown = if d.default.is_empty() { tl!("(none)").to_string() } else { d.default.clone() }; - egui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { ui.selectable_value(&mut d.default, String::new(), tl!("(none)")); for o in d.options.clone() { ui.selectable_value(&mut d.default, o.clone(), o); diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index 730061c61f..b74e67dd19 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -35,7 +35,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme let mut picked = cat; ui.horizontal(|ui| { ui.label(tl!("Select format category:")); - egui::ComboBox::from_id_salt("format-cat") + craft_ui::ComboBox::from_id_salt("format-cat") .selected_text(tl!(match cat { Category::None => "None", Category::Number => "Number", @@ -87,7 +87,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); } @@ -95,7 +95,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.end_row(); ui.label(tl!("Currency symbol:")); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("fmt-cur") + craft_ui::ComboBox::from_id_salt("fmt-cur") .selected_text(if currency.is_empty() { tl!("None") } else { currency.as_str() }) .width(70.0) .show_ui(ui, |ui| { @@ -107,7 +107,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme }); ui.end_row(); ui.label(tl!("Negative number style:")); - egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { for (i, s) in NEGATIVES.iter().enumerate() { ui.selectable_value(neg, i as u8, *s); } @@ -121,7 +121,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); } @@ -137,7 +137,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme }; ui.horizontal(|ui| { ui.label(tl!("Format:")); - egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { for pr in presets { ui.selectable_value(p, pr.to_string(), pr); } @@ -148,7 +148,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme } Format::Special(n) => { let mut idx = *n as usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { ui.selectable_value(&mut idx, i, tl!(s)); } @@ -161,7 +161,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme } Format::Mask(m) => { let mut idx = 4usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { ui.selectable_value(&mut idx, i, tl!(s)); } @@ -220,7 +220,7 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them ui.label(tl!("Select Trigger:")); // Editing changes the script of one trigger's action: its trigger and kind stay. ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { for tr in T::ALL { ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); } @@ -229,7 +229,7 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them ui.end_row(); ui.label(tl!("Select Action:")); ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { for (i, k) in ACTION_KINDS.iter().enumerate() { ui.selectable_value(&mut a.kind, i, tl!(k)); } @@ -350,7 +350,7 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th if let Calculate::Simple { op, fields } = &mut d.calculate { ui.horizontal(|ui| { ui.add_space(24.0); - egui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { for o in CalcOp::ALL { ui.selectable_value(op, o, tl!(o.label())); } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 6a5bee7ea4..8e7ac02eef 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -412,7 +412,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> ui.add_space(6.0); widgets::radio_value(ui, &mut d.mode, SearchMode::Patterns, tl!("Patterns")); ui.add_enabled_ui(d.mode == SearchMode::Patterns, |ui| { - egui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { for p in REDACT_PATTERNS { ui.selectable_value(&mut d.pattern, p, tl!(p.label())); } @@ -475,7 +475,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> } let codes_on = on && d.use_code; ui.add_enabled_ui(codes_on, |ui| { - egui::ComboBox::from_id_salt("redaction-code-set").selected_text(d.code_set.name).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("redaction-code-set").selected_text(d.code_set.name).show_ui(ui, |ui| { for set in REDACTION_CODE_SETS { if ui.selectable_label(d.code_set == set, set.name).clicked() && d.code_set != set { d.code_set = set; @@ -505,7 +505,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> let look = &mut d.look; ui.label(tl!("Font:")); ui.add_enabled_ui(on, |ui| { - egui::ComboBox::from_id_salt("overlay-font").selected_text(look.font.name()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("overlay-font").selected_text(look.font.name()).show_ui(ui, |ui| { for f in pdfcraft_engine::OverlayFont::ALL { ui.selectable_value(&mut look.font, f, f.name()); } diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index 4b327923d4..ad5ef4b01d 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -163,7 +163,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); ui.end_row(); ui.label(tl!("Key Algorithm")); - egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { ui.selectable_value(&mut n.key, i, *label); } @@ -415,7 +415,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { 3 => tl!("Annotations, form fill-in, and digital signatures"), _ => tl!("Form fill-in and digital signatures"), }; - egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { for v in [1u8, 2, 3] { ui.selectable_value(&mut p, v, label(v)); } diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 1da56c28ed..9cddb31d23 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -67,7 +67,7 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.horizontal(|ui| { ui.add(crate::widgets::line(&mut d.category).desired_width(200.0).hint_text(tl!("e.g. My stamps"))).labelled_by(l.id); if !categories.is_empty() { - egui::ComboBox::from_id_salt("stamp-categories").selected_text("").width(24.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("stamp-categories").selected_text("").width(24.0).show_ui(ui, |ui| { for c in &categories { if ui.selectable_label(d.category == *c, c).clicked() { d.category = c.clone(); diff --git a/crates/ui-egui/src/workflows/a11y_ui.rs b/crates/ui-egui/src/workflows/a11y_ui.rs index 2130c927a0..9081e5cb32 100644 --- a/crates/ui-egui/src/workflows/a11y_ui.rs +++ b/crates/ui-egui/src/workflows/a11y_ui.rs @@ -103,7 +103,7 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) group(ui, &crate::i18n::fmt(tl!("Checking Options ({on} of 32 in all categories)"), &[("on", &on.to_string())]), &mut |ui| { ui.horizontal(|ui| { ui.label(tl!("Category:")); - egui::ComboBox::from_id_salt("a11y-category").selected_text(tl!(o.category.label())).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("a11y-category").selected_text(tl!(o.category.label())).width(260.0).show_ui(ui, |ui| { for c in Category::ALL { ui.selectable_value(&mut o.category, c, tl!(c.label())); } diff --git a/crates/ui-egui/src/workflows/actions_ui.rs b/crates/ui-egui/src/workflows/actions_ui.rs index ddd8a0736c..5ef299102f 100644 --- a/crates/ui-egui/src/workflows/actions_ui.rs +++ b/crates/ui-egui/src/workflows/actions_ui.rs @@ -345,11 +345,14 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let all = Step::all(); let add = &mut app.wizard.add; ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("action-add-step").selected_text(tl!(all[(*add).min(all.len() - 1)].label())).width(240.0).show_ui(ui, |ui| { - for (i, s) in all.iter().enumerate() { - ui.selectable_value(add, i, tl!(s.label())); - } - }); + craft_ui::ComboBox::from_id_salt("action-add-step").selected_text(tl!(all[(*add).min(all.len() - 1)].label())).width(240.0).show_ui( + ui, + |ui| { + for (i, s) in all.iter().enumerate() { + ui.selectable_value(add, i, tl!(s.label())); + } + }, + ); if ui.button(tl!("Add Step")).clicked() { a.steps.push(all[(*add).min(all.len() - 1)].clone()); } diff --git a/crates/ui-egui/src/workflows/compare_ui.rs b/crates/ui-egui/src/workflows/compare_ui.rs index fcaa963278..939d5fed3e 100644 --- a/crates/ui-egui/src/workflows/compare_ui.rs +++ b/crates/ui-egui/src/workflows/compare_ui.rs @@ -63,7 +63,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.label(tl!("Old file:")); let shown = others.iter().find(|(id, _)| Some(*id) == app.compare_old).map_or(tl!("Open the older version first").to_string(), |(_, n)| n.clone()); - egui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| { for (id, n) in &others { ui.selectable_value(&mut app.compare_old, Some(*id), n); } diff --git a/crates/ui-egui/src/workflows/export_ui.rs b/crates/ui-egui/src/workflows/export_ui.rs index bcda63c57b..0af69f1823 100644 --- a/crates/ui-egui/src/workflows/export_ui.rs +++ b/crates/ui-egui/src/workflows/export_ui.rs @@ -53,7 +53,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E if kind == ExportKind::Image { ui.horizontal(|ui| { ui.label(tl!("Resolution")); - egui::ComboBox::from_id_salt("export-dpi") + craft_ui::ComboBox::from_id_salt("export-dpi") .selected_text(crate::i18n::fmt(tl!("{dpi} pixels/inch"), &[("dpi", &d.dpi.to_string())])) .show_ui(ui, |ui| { for dpi in [72.0, 96.0, 150.0, 300.0, 600.0] { @@ -67,7 +67,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E ImageFormat::Jpeg { quality } => quality, _ => 85, }; - egui::ComboBox::from_id_salt("export-format").selected_text(d.format.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("export-format").selected_text(d.format.label()).show_ui(ui, |ui| { for f in [ImageFormat::Png, ImageFormat::Jpeg { quality }, ImageFormat::Tiff] { let on = std::mem::discriminant(&d.format) == std::mem::discriminant(&f); if ui.selectable_label(on, f.label()).clicked() { @@ -93,7 +93,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E let label = move |n: u32| { if n == 0 { tl!("No limit").to_string() } else { crate::i18n::trn(crate::i18n::current(), u64::from(n), "{n} pixel", "{n} pixels") } }; - egui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { for n in [0, 16, 32, 64, 128, 256] { ui.selectable_value(&mut d.min_side, n, label(n)); } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 84aa706576..0ebdbb07a6 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -332,7 +332,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok (selected, picker) }; let before = app.language.clone(); - egui::ComboBox::from_id_salt("interface-language") + craft_ui::ComboBox::from_id_salt("interface-language") .selected_text(selected) .show_ui(ui, |ui| { if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { @@ -359,7 +359,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - egui::ComboBox::from_id_salt("interface-size") + craft_ui::ComboBox::from_id_salt("interface-size") .selected_text(selected) .show_ui(ui, |ui| { if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { @@ -485,7 +485,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.horizontal(|ui| { let label = ui.label(tl!("Replace document colours")); let mode = app.reading_colors.mode_name(); - egui::ComboBox::from_id_salt("reading-colors") + craft_ui::ComboBox::from_id_salt("reading-colors") .selected_text(tl!(reading_mode_label(mode))) .show_ui(ui, |ui| { for (m, label) in [ @@ -590,7 +590,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { let label = ui.label(tl!("Fill & Sign dates")); let current = app.session.date_format().to_string(); let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; - egui::ComboBox::from_id_salt("date-format") + craft_ui::ComboBox::from_id_salt("date-format") .selected_text(selected) .width(240.0) .show_ui(ui, |ui| { @@ -652,7 +652,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); (follow, selected, current, names) }; - egui::ComboBox::from_id_salt("date-language") + craft_ui::ComboBox::from_id_salt("date-language") .selected_text(selected) .width(240.0) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/workflows/ocr_ui.rs b/crates/ui-egui/src/workflows/ocr_ui.rs index 977f90f2f9..04e257d52d 100644 --- a/crates/ui-egui/src/workflows/ocr_ui.rs +++ b/crates/ui-egui/src/workflows/ocr_ui.rs @@ -121,21 +121,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo egui::Grid::new("ocr-settings").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Document language")); let name = LANGUAGES.iter().find(|l| l.0 == d.language).map_or("English", |l| l.1); - egui::ComboBox::from_id_salt("ocr-language").selected_text(tl!(name)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-language").selected_text(tl!(name)).width(220.0).show_ui(ui, |ui| { for (code, name) in LANGUAGES { ui.selectable_value(&mut d.language, (*code).to_string(), tl!(*name)); } }); ui.end_row(); ui.label(tl!("Output")); - egui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| { let _ = ui .selectable_label(true, tl!("Searchable Image (Exact)")) .on_hover_text(tl!("Adds invisible text over each word; the page image is not changed")); }); ui.end_row(); ui.label(tl!("Downsample to")); - egui::ComboBox::from_id_salt("ocr-dpi").selected_text(format!("{} dpi", d.dpi)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-dpi").selected_text(format!("{} dpi", d.dpi)).width(220.0).show_ui(ui, |ui| { for v in [600, 300, 150, 72] { ui.selectable_value(&mut d.dpi, v, format!("{v} dpi")); } diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index d46d7ae591..3bb80ac439 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -121,7 +121,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { Compression::Flate => tl!("ZIP"), Compression::Retain => tl!("Retain existing"), }; - egui::ComboBox::from_id_salt((id, "compression")).selected_text(label).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt((id, "compression")).selected_text(label).show_ui(ui, |ui| { let q = match s.compression { Compression::Jpeg(q) => q, _ => 60, @@ -133,7 +133,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { if let Compression::Jpeg(q) = &mut s.compression { ui.label(tl!("Quality")); let name = QUALITIES.iter().min_by_key(|(_, v)| (*v as i32 - *q as i32).abs()).map_or(tl!("Medium"), |(n, _)| tl!(n)); - egui::ComboBox::from_id_salt((id, "quality")).selected_text(name).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt((id, "quality")).selected_text(name).show_ui(ui, |ui| { for (n, v) in QUALITIES { ui.selectable_value(q, v, tl!(n)); } @@ -165,7 +165,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo Preset::Mobile => tl!("Mobile"), }; let current = Preset::of(&d.settings); - egui::ComboBox::from_id_salt("optimize-preset").selected_text(current.map_or(tl!("Custom"), name)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("optimize-preset").selected_text(current.map_or(tl!("Custom"), name)).show_ui(ui, |ui| { for p in Preset::ALL { if ui.selectable_label(current == Some(p), name(p)).clicked() { d.settings = p.settings(); @@ -178,7 +178,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo Compatibility::Retain => tl!("Retain existing").to_string(), _ => format!("PDF {}", c.name()), }; - egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { for c in Compatibility::ALL { ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c)); } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index baf5c02db0..5243820396 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -36,7 +36,7 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); let mut pick = current.clone(); let option_label = ui.label(&o.label); - egui::ComboBox::from_id_salt(("driver-option", &o.key)) + craft_ui::ComboBox::from_id_salt(("driver-option", &o.key)) .selected_text(label_of(¤t)) .width(200.0) .show_ui(ui, |ui| { @@ -64,7 +64,7 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { /// Draw a combo box whose accessible name does not depend on the selected value. fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); - egui::ComboBox::from_id_salt(id) + craft_ui::ComboBox::from_id_salt(id) .selected_text(shown) .width(width) .show_ui(ui, |ui| { @@ -107,7 +107,7 @@ pub(crate) fn body( None if still_looking(d) => tl!("Looking for printers…").to_string(), None => tl!("Save as PDF").to_string(), }; - egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { if d.printers_loading { ui.add_enabled(false, egui::Label::new(tl!("Looking for printers…"))); } @@ -201,7 +201,7 @@ pub(crate) fn body( let paper_label = ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; - egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, tl!(name)); } From 0cd6a197e40e31b1c0dd0cb84b047f6df1e295be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:47:34 +0000 Subject: [PATCH 053/120] 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 dfb74c7990d14d96f63199f1f782495931915e6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:52:54 +0000 Subject: [PATCH 054/120] ci: use storytold/craft-common-actions windows-arm64 workflow --- .github/workflows/windows-arm64.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 85238e99d7..838af4524c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,7 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: pdfcraft app-dir: PdfCraft From cace97e7b2607d31e703bb04c17fa7975f40ec39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:46:00 +0000 Subject: [PATCH 055/120] 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 056/120] 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 7d1ed99f51b25c08107be4b2495a5e6a22c56058 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:50 +0000 Subject: [PATCH 057/120] Refactor annot for strict Clippy checks --- crates/annot/src/appearance.rs | 1112 +++++++++++++----------- crates/annot/src/appearance/symbols.rs | 2 +- crates/annot/src/create.rs | 90 +- crates/annot/src/draw.rs | 2 +- crates/annot/src/info.rs | 2 +- crates/annot/src/lookup.rs | 2 +- crates/annot/src/modify.rs | 2 +- crates/annot/src/types.rs | 2 +- 8 files changed, 664 insertions(+), 550 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 508d2bee45..abc746c0dd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -373,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -390,520 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); - } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); - } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); } } - Some(form(rect, &out, res)) + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } + } + } + Some(()) +} + +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + Some(()) +} + +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); + } + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); + } + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { + return None; + } + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; + } + } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; + } + Some(names) + } + } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); + } + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), + n(y0 + h / 2.0), + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); + let mut out = c.into_bytes(); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); + out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; + } + out.extend_from_slice(b"ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + Some(stream) +} + +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index d49224cd9f..3b33662524 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; /// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp /// another program wrote without an appearance. Names are matched byte for byte, as PDF names diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index bedd1c6f15..a6db5b940b 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,8 +1,10 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; use super::modify::{n, rich_text}; -use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style}; -use super::*; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; pub(crate) fn num_array(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) @@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { let page = page_ref(doc, new.page)?; let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } + validate_style(style)?; let rect = rect_for(&new.shape, style)?; let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } + set_non_opaque_style(&mut d, opacity); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), Shape::Rectangle { .. } | Shape::Oval { .. } => { d.set(b"C".to_vec(), rgb(style.color)); if let Some(f) = style.fill { @@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let mut list = annots(doc, page); let index = list.len(); list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } + add_popup(doc, r, popup, &mut list)?; set_annots(doc, page, list)?; Ok(index) } +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 1898454415..5e698332da 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -2,7 +2,7 @@ use super::create::{finite, num_array}; use super::lookup::{annot_dict, annots, page_refs}; use super::modify::refuse_foreign_text; use super::types::NOTE_SIZE; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index bea14d9ee2..b56e0de20d 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -3,7 +3,7 @@ use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 00ce23c37f..c81ec78ef4 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,6 +1,6 @@ use super::info::text_value; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; // ── pages and /Annots ─────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 992b3d0c51..7872a6c817 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -7,7 +7,7 @@ use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, s use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, }; -use super::*; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; /// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would /// replace those characters with `?`, so the edit is refused before the dictionary changes. diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 632e990fea..40ed744df3 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::*; +use super::{ObjRef, appearance}; pub type Rgb = [f64; 3]; From 9d97546081c9e83acdd81b46c801ccfa8ff86cdd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 058/120] Refactor automation for strict Clippy checks --- crates/automation/src/a11y/documents.rs | 2 +- crates/automation/src/a11y/form_tools.rs | 2 +- crates/automation/src/a11y/ocr.rs | 2 +- crates/automation/src/assemble.rs | 2 +- crates/automation/src/dispatch.rs | 2 +- crates/automation/src/documents.rs | 2 +- crates/automation/src/json.rs | 2 +- crates/automation/src/pages.rs | 2 +- crates/automation/src/paths.rs | 2 +- crates/automation/tests/it/automation.rs | 192 +++++++++++++++-------- 10 files changed, 137 insertions(+), 73 deletions(-) diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f623ef714e..f2c9f6b77d 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5fb14f136e..7272a8d669 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; /// A script's request to the viewer as the JSON that running a script reports; a side panel /// goes by its fixed id. diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index afc671a8c7..82e521ddd5 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 40ae65ea87..deb2efa18a 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based, summary}; use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; -use super::*; +use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json}; impl Automation { pub(crate) fn doc_create(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 26e1f95365..9ccd18c558 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; use super::paths::{read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; /// One piece of a tool's result. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 3ce312f395..476c451028 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c8710aa509..6f0884226a 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -1,5 +1,5 @@ use super::error::{Result, ToolError}; -use super::*; +use super::{Document, Value, comments, json}; /// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at /// most 14 400 across). diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index d9ea9e5c4f..647a72e1d4 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,7 +1,7 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based}; -use super::*; +use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. pub(crate) const DEFAULT_DPI: f64 = 96.0; diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs index fb514df69f..e13d0a1452 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::*; +use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..78038d8ebd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2030,21 +2030,33 @@ fn comments_through_tools() { let dir = workdir("comments"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = add_comment_examples(&mut a, doc); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = verify_comment_listing(&list, &id); + edit_rectangle_comment(&mut a, doc, &rect); + edit_text_comment(&mut a, doc, &list); + verify_comment_errors(&mut a, doc, &id); + save_and_reopen_comments(&dir, &mut a, doc); +} + +fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. - let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); + let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); assert_eq!(hl["comment"]["lines"], 1); let id = hl["comment"]["id"].as_str().unwrap().to_string(); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); - ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); - ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); + ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); + ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); + ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + id +} - let list = ok(&mut a, "comment_list", json!({ "doc": doc })); +fn verify_comment_listing(list: &Value, id: &str) -> Value { assert_eq!(list["count"], 6, "{list}"); - let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone(); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check"))); assert_eq!(c["status"], "Accepted"); assert_eq!(c["replies"].as_array().unwrap().len(), 1); @@ -2057,33 +2069,38 @@ fn comments_through_tools() { assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0])); let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone(); assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point"); + rect +} +fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) { // Edit by page + index; several changes are one undo step. - let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap()); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap()); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone(); assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved"))); assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0])); - let undo = ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undo = ok(a, "edit_undo", json!({ "doc": doc })); assert_eq!(undo["undone"], "Edit comment"); - ok(&mut a, "edit_redo", json!({ "doc": doc })); + ok(a, "edit_redo", json!({ "doc": doc })); // Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none. - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_)))); - let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); + let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); assert!(matches!( a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })), Err(ToolError::Failed(_)) )); +} +fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) { // Editing a text box re-fits its rectangle to the new text: the wrap width and top edge // stay, the height follows the wrapped lines. - let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); + let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let long = "the quick brown fox jumps over the lazy dog ".repeat(3); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}"); @@ -2092,9 +2109,11 @@ fn comments_through_tools() { // The rendered page shows the rectangle's border. let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); +} - ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5); +fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) { + ok(a, "comment_delete", json!({ "doc": doc, "id": id })); + assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5); assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_)))); @@ -2103,10 +2122,11 @@ fn comments_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_)))); +} - // Comments survive a save and reopen. - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); - let mut b = auto(&dir); +fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) { + ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); + let mut b = auto(dir); let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } @@ -2809,6 +2829,12 @@ fn accessibility_check_report_and_fixes_through_tools() { let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap(); assert!(manual_html.contains("

Needs manual check: 1

")); assert!(!manual_html.contains("no problems")); +} + +#[test] +fn accessibility_figure_tags_and_alternative_text_through_tools() { + let dir = workdir("a11y-figures"); + let mut a = auto(&dir); // Figures: list, describe, mark decorative. std::fs::write( dir.join("figures.pdf"), @@ -5260,6 +5286,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { + let root = local_gotor_fixtures(); + let mut a = auto(&root); + assert_relative_gotor_destination(&mut a); + assert_absolute_gotor_policy(&mut a); + assert_unapplied_gotor_destinations(&mut a); + assert_encrypted_gotor_passwords(&mut a); + assert_unsupported_gotor_targets(&mut a); + assert_gotor_root_boundaries(&mut a); + assert_url_links_cannot_open_as_gotor(&mut a); +} + +fn local_gotor_fixtures() -> PathBuf { let (base, root) = sandbox("gotor-open"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("target.pdf"), fixture(3)).unwrap(); @@ -5277,12 +5315,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap(); link_dir(&root, "outside-link", &base.join("outside")); std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap(); + root +} - let mut a = auto(&root); - let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); - let listed = ok(&mut a, "link_list", json!({ "doc": source })); +fn assert_relative_gotor_destination(a: &mut Automation) { + let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(a, "link_list", json!({ "doc": source })); assert_eq!(listed["links"][0]["other"], "GoToR"); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination_applied"], true); assert_eq!(opened["destination"]["page"], 3); @@ -5290,51 +5330,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(opened["document"]["pages"], 3); let target_id = opened["document"]["doc"].as_u64().unwrap(); assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); +} - let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); +fn assert_absolute_gotor_policy(a: &mut Automation) { + let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); let before = a.session().docs().len(); for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { - assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); assert_eq!(a.session().docs().len(), before); } - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); + let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); +} +fn assert_unapplied_gotor_destinations(a: &mut Automation) { for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["destination_applied"], false); assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}"); } +} - let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); +fn assert_encrypted_gotor_passwords(a: &mut Automation) { + let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); + let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); assert_eq!(opened["document"]["encrypted"], true); +} +fn assert_unsupported_gotor_targets(a: &mut Automation) { for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}"); } +} +fn assert_gotor_root_boundaries(a: &mut Automation) { for source_path in ["sub/escape.pdf", "symlink.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened"); } +} - let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); +fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) { + let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); assert!( - matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) + matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) ); } @@ -5887,8 +5939,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); let mut a = auto(&dir); + create_signing_identity(&mut a); + let doc = sign_document_and_verify_trust(&mut a, &dir); + verify_signature_revisions_and_bundle(&mut a, doc, &dir); +} + +fn create_signing_identity(a: &mut Automation) { let id = ok( - &mut a, + a, "sign_id_create", json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); @@ -5897,13 +5955,15 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); // Eight characters at least (#1847): seven is refused. assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); +} - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); +fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { + let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0); // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); - let store = ok(&mut a, "sign_windows_ids", json!({})); + let store = ok(a, "sign_windows_ids", json!({})); let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); @@ -5917,7 +5977,7 @@ fn digital_ids_signing_and_validation_through_tools() { Err(ToolError::InvalidArgs(_)) )); let r = ok( - &mut a, + a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); @@ -5929,33 +5989,37 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); + let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); - let list = ok(&mut a, "sign_list", json!({ "doc": doc })); + let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); - let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]; + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); + let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); + doc +} + +fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) { // A full rewrite is refused for a signed document. assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_)))); - ok(&mut a, "doc_save", json!({ "doc": doc })); + ok(a, "doc_save", json!({ "doc": doc })); // Three revisions: the original, the signature, the comment; the middle one is signed. - let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); + let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); assert_eq!(revs.len(), 3, "{revs:?}"); assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}"); - let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1); + let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); + assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1); assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); - assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. use pdfcraft_engine::sign::{cms, der, pkcs12}; let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); - assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); + assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. From 84c873d2e1af5348455f226cd770708d725a31c7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 059/120] Refactor cos for strict Clippy checks --- crates/cos/src/document/objects.rs | 2 +- crates/cos/src/document/open.rs | 2 +- crates/cos/src/document/reader.rs | 2 +- crates/cos/src/document/state.rs | 2 +- crates/cos/src/document/xref.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ff729f1fb..8744a3ea3e 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::*; +use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index c90d5c0906..d41d564e33 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,7 @@ use super::reader::{find, generated_id}; use super::state::Credentials; use super::xref::XrefIndex; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index ead09c36e5..66129c1423 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -3,7 +3,7 @@ use super::state::SECTION_READS; use super::xref::{ MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, }; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared}; impl Document { #[allow(clippy::type_complexity)] diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 30f193e4e6..b4323f982e 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -1,5 +1,5 @@ use super::xref::XrefEntry; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 96a782c1a9..02390ff7da 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Lexer}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum XrefEntry { From de73c35e174aa84284a4c995ab2341c0a00e190f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 060/120] Refactor edit for strict Clippy checks --- crates/edit/src/added/draw.rs | 5 +- crates/edit/src/added/layout.rs | 5 +- crates/edit/src/added/store.rs | 2 +- crates/edit/src/added/types.rs | 2 +- crates/edit/src/apply.rs | 2 +- crates/edit/src/marks.rs | 2 +- crates/edit/src/place.rs | 2 +- crates/edit/src/tests.rs | 193 +++-- crates/edit/src/text/blocks.rs | 2 +- crates/edit/src/text/editor.rs | 2 +- crates/edit/src/text/fallback.rs | 4 +- crates/edit/src/text/interp.rs | 532 ++++++------ crates/edit/src/text/lines.rs | 2 +- crates/edit/src/text/rewrite.rs | 936 ++++++++++++-------- crates/edit/src/text_regions.rs | 7 +- crates/edit/src/text_regions/grouping.rs | 2 +- crates/edit/src/text_regions/styled.rs | 1010 +++++++++++++--------- crates/edit/src/text_session.rs | 5 +- 18 files changed, 1634 insertions(+), 1081 deletions(-) diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 0fc7aa510d..61297bcf24 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -2,7 +2,10 @@ use super::layout::{ Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped, }; use super::types::{AddedText, Align, Family}; -use super::*; +use super::{ + Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, + literal, matrix_number, n, shaped_glyph, win_ansi, +}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 0e971b92c7..67d80be405 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,5 +1,8 @@ use super::types::{AddedImage, AddedText}; -use super::*; +use super::{ + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, + shape_thai, +}; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index b89d429000..5ce5e15d19 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -1,7 +1,7 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; -use super::*; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged}; fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 0a9f92f92c..28b61d5869 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -1,5 +1,5 @@ use super::layout::norm; -use super::*; +use super::{Content, MarkKind, ObjRef, Std14}; pub(super) const TAG: &str = "Added"; diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 1461dc78ef..427f51f943 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::*; +use super::{Document, EditError, Object, added, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 507e2afa8e..00c53ec27a 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EditError, Object, Rgb, added}; /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index b6d50a6462..2f2c24c862 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ use super::marks::{MarkKind, matrix_number, n}; -use super::*; +use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0f8adc6514..7c78d31fc2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6664,12 +6664,124 @@ fn actual_text_pairs_past_the_marked_content_cap() { } } -/// #2992: the colour a page is painted with before anything else is its background, for exports -/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes -/// behind; a page that paints anything else first, a fill that doesn't cover the page, a -/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap() +} + +fn assert_page_background_fill_cases(gs: &str) { + assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "one of the rectangles covers it" + ); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), + Some([1.0, 0.0, 0.0]), + "nonzero, same winding: no hole" + ); + assert_eq!( + page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), + None, + "reversed by a mirroring cm" + ); + assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), + None, + "a page fill with a hole cut in it" + ); +} + +fn assert_page_background_transform_and_clip_cases() { + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), + None, + "a turned fill short of the corners" + ); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned fill over them" + ); + // The same for a clip. + assert_eq!( + page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + None, + "a turned clip" + ); + assert_eq!( + page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "two covering clips" + ); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + None, + "the second clip doesn't cover" + ); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +/// #2992: page background detection follows the first full-page fill under text and ignores +/// partial, translucent, clipped, or unsupported fills. #[test] fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; let mut doc = fixture(); assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); @@ -6680,77 +6792,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); assert!(page_background(&doc, 9).is_err()); - let page = |content: &str, resources: &str| { - let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); - page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() - }; - let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; - assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); - assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); - assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); - assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); - // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the - // other way under nonzero. - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); - assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); - assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); - assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); - assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); - assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); - assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); - assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); - assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); - // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop - // box's corners are inside it; its bounding box covering the crop box isn't enough. This one - // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but - // 354 along the diagonals, short of the corners. One reaching 707 covers them. - let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; - assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); - assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); - // The same for a clip. - assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); - assert_eq!( - page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), - Some([1.0, 0.0, 0.0]), - "a turned clip over the corners" - ); - // Clips add up: two that each cover the page leave it covered, one that doesn't among them - // doesn't. - assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); - assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); - // Past 64 clipping rectangles the clip counts as unknown. - let clips = "0 0 600 800 re W n ".repeat(65); - assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); - // A rectangle drawn the other way round (negative width) covers the page all the same. - assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); - // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the - // edge of that included. - assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); - assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); - assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); + assert_page_background_fill_cases(gs); + assert_page_background_transform_and_clip_cases(); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 8d3d224da0..615e7699a2 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,7 +1,7 @@ use super::interp::{Carry, interpret}; use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; -use super::*; +use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; /// An editable text region: adjacent runs on visual rows, followed by aligned rows. /// A region may contain several source fonts, sizes, colours and weights. diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index b2bd2b9f97..f7aa1f47bd 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::*; +use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; /// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and /// are never installed, copied into assets, or used as a font for another document. diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 0c19a6680a..b8f45a221f 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,6 +1,8 @@ use super::interp::Ts; use super::lines::page_dict; -use super::*; +use super::{ + CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, +}; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. pub(super) type Encoder = Box Option)>>>; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index ca0d63cef4..e4248aedea 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -1,6 +1,6 @@ use super::fallback::is_type3_fallback; use super::rewrite::vertical_advance; -use super::*; +use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc}; /// Text and graphics state captured for rewriting. #[derive(Clone, Debug, PartialEq)] @@ -472,6 +472,280 @@ type CachedFont = (Rc, Option, Result, String>); /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); +#[derive(Default)] +struct MarkedContent { + regions: Vec>, + layers: Vec>>, + actual_text: Vec>, +} + +impl MarkedContent { + fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) { + // Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and + // inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + self.layers + .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value { + Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()), + _ => None, + }); + self.actual_text.push(logical.map(|text| (shown_count, index, text))); + } + + fn end(&mut self, index: usize, shown: &mut Vec) { + self.regions.pop(); + self.layers.pop(); + if let Some(Some((start, open, logical))) = self.actual_text.pop() { + let tail = shown.split_off(start.min(shown.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); + } + } + } +} + +struct TextRunState<'a> { + ts: &'a mut Ts, + tm: &'a mut Matrix, + tlm: &'a mut Matrix, + at_bt: &'a (usize, Ts), + bt: usize, + marked: &'a MarkedContent, + carry: &'a mut Carry, + out: &'a mut Vec, +} + +fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { + let ts = &mut *state.ts; + let tm = &mut *state.tm; + let tlm = &mut *state.tlm; + let at_bt = state.at_bt; + let bt = state.bt; + let marked = state.marked; + let carry = &mut *state.carry; + let out = &mut *state.out; + if matches!(op.op.as_slice(), b"'" | b"\"") { + if op.is("\"") { + ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); + ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); + } + *tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm); + *tm = *tlm; + } + let Some((name, m)) = ts.font.clone() else { return Ok(()) }; + // Pieces: strings, and TJ number adjustments. + let pieces: Vec = match op.op.as_slice() { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; + let trm0 = tm.then(&ts.ctm); + let start = trm0.apply(0.0, ts.rise); + let mut text = String::new(); + let mut decodable = true; + let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; + let mut glyph_count = 0_usize; + for p in &pieces { + match p { + Object::String(s) => { + for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); + let shown = m.text_of(code); + let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } + None => decodable = false, + } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + x_text += w * ts.scale; + } + } + other => { + if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } + let dx = -n / 1000.0 * ts.size * ts.scale; + // A large gap inside TJ reads as a space. + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } + x_text += dx; + } + } + } + } + *tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm); + let end = tm.then(&ts.ctm).apply(0.0, ts.rise); + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)], + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); + let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); + let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + return Ok(()); + } + out.push(Shown { + op: i, + extra_ops: Vec::new(), + glyph_count, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, + tracking: 0.0, + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), + state: ts.clone(), + tlm: *tlm, + bt_op: at_bt.0, + bt_state: at_bt.1.clone(), + text, + rect, + frame, + ink, + baseline: start.1, + start_x: start.0, + end_x: end.0, + end_y: end.1, + bt, + font: name, + base_font: m.base_font.clone(), + face: ts.face.clone(), + size: if size_user > 0.0 { size_user } else { ts.size }, + bold: m.bold, + italic: m.italic, + visual_order: m.visual_order, + decodable, + scope: marked.regions.last().copied().flatten(), + paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, + logical_order: false, + actual_text: false, + }); + Ok(()) +} + pub(super) fn interpret( doc: &Document, ops: &[Op], @@ -490,66 +764,17 @@ pub(super) fn interpret( let mut stack: Vec = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; - let mut marked = std::mem::take(&mut carry.marked); - let mut layers = std::mem::take(&mut carry.layers); - let mut actual: Vec> = Vec::new(); + let mut marked = + MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - // A layer named in the resources is told apart by its name, an inline one by where it opens. - let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); - layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); - // The property list is inline or named in the resources' /Properties (§14.6.2). - let list = logical_text.and_then(|properties| match op.operands.get(1) { - Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), - other => other.and_then(Object::as_dict).cloned(), - }); - let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(logical.map(|s| (out.len(), i, s))); + marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { - marked.pop(); - layers.pop(); - if let Some(Some((start, open, logical))) = actual.pop() { - let tail = out.split_off(start.min(out.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for shown in items { - first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); - first.extra_ops.push(shown.op); - first.extra_ops.extend(shown.extra_ops); - first.rect = [ - first.rect[0].min(shown.rect[0]), - first.rect[1].min(shown.rect[1]), - first.rect[2].max(shown.rect[2]), - first.rect[3].max(shown.rect[3]), - ]; - first.frame = union(first.frame, shown.frame); - first.ink.extend(shown.ink); - } - first.extra_ops.extend([open, i]); - // Only right-to-left ActualText is already in typing order and must stay - // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures - // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - out.push(first); - } - } + marked.end(i, &mut out); } b"q" => stack.push(ts.clone()), b"Q" => { @@ -650,207 +875,16 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - if matches!(op.op.as_slice(), b"'" | b"\"") { - if op.is("\"") { - ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); - ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - let Some((name, m)) = ts.font.clone() else { continue }; - // Pieces: strings, and TJ number adjustments. - let pieces: Vec = match op.op.as_slice() { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569) -- not the first, which a reader counting operands - // from the front of a surplus list would otherwise take. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Tc is already included in the pen advance; TJ may encode tracking instead. - let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; - let trm0 = tm.then(&ts.ctm); - let start = trm0.apply(0.0, ts.rise); - let mut text = String::new(); - let mut decodable = true; - let mut x_text = 0.0; - // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred - // on it horizontally; `half` is half the widest glyph. - let vertical = ts.vertical; - let (mut y_text, mut half) = (0.0, 0.0_f64); - // Where each word's glyphs start and end along the run (text space), and whether - // the next glyph continues the last word. - let mut words: Vec<(f64, f64)> = Vec::new(); - let mut in_word = false; - let mut glyph_count = 0_usize; - for p in &pieces { - match p { - Object::String(s) => { - for (code, len) in m.codes(&s.bytes) { - glyph_count = glyph_count.saturating_add(1); - let shown = m.text_of(code); - let blank = - m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); - match shown { - Some(t) => { - carry.text_left = carry - .text_left - .checked_sub(t.len()) - .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); - } - None => decodable = false, - } - let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - if let Some(w1) = vertical { - half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); - if !blank { - let ink_end = y_text + w1 * ts.size; - let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - y_text += w1 * ts.size + spacing; - continue; - } - if !blank { - let ink_end = x_text + m.width(code) * ts.size * ts.scale; - // A negative font size or `Tz` runs the glyphs backwards: - // the word is the union of its glyphs, whichever way they go. - let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - x_text += w * ts.scale; - } - } - other => { - if let Some(n) = other.as_f64() { - if vertical.is_some() { - // Down by n/1000 em, not scaled by `Tz`. - let dy = -n / 1000.0 * ts.size; - if -dy > ts.size * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - if dy.abs() > ts.size.abs() * 0.2 { - in_word = false; - } - y_text += dy; - continue; - } - let dx = -n / 1000.0 * ts.size * ts.scale; - // A large gap inside TJ reads as a space. - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - // Tracking and kerning keep a word together; a larger jump - // ends it (whatever the sign of the size or `Tz`). - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { - in_word = false; - } - x_text += dx; - } - } - } - } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm); - let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box from `a` to `b` along the run: from descent to ascent across it, or for - // vertical text, the widest glyph's width centred on the pen. - let span = |a: f64, b: f64| match vertical { - Some(_) => { - [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)] - } - None => [ - trm0.apply(a, ts.rise + m.descent * ts.size), - trm0.apply(b, ts.rise + m.descent * ts.size), - trm0.apply(a, ts.rise + m.ascent * ts.size), - trm0.apply(b, ts.rise + m.ascent * ts.size), - ], - }; - let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); - let rect = corners - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - // The same corners along and across the baseline: the run's own box, not its - // upright bounding box turned (which is wider by the box's height on a slant). - let dir = direction(&trm0); - let frame = corners - .iter() - .map(|&p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - let ink = words - .iter() - .map(|&(a, b)| { - span(a, b) - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) - }) - .collect(); - let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; - // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { - continue; - } - out.push(Shown { - op: i, - extra_ops: Vec::new(), - glyph_count, - // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the - // preview divides by size × `Tz`, so `Tz` is put back here. - advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, - tracking: 0.0, - tm: Matrix([ - tm.0[0], - tm.0[1], - tm.0[2], - tm.0[3], - tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], - tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], - ]), - state: ts.clone(), - tlm, - bt_op: at_bt.0, - bt_state: at_bt.1.clone(), - text, - rect, - frame, - ink, - baseline: start.1, - start_x: start.0, - end_x: end.0, - end_y: end.1, - bt, - font: name, - base_font: m.base_font.clone(), - face: ts.face.clone(), - size: if size_user > 0.0 { size_user } else { ts.size }, - bold: m.bold, - italic: m.italic, - visual_order: m.visual_order, - decodable, - scope: marked.last().copied().flatten(), - paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, - logical_order: false, - actual_text: false, - }); + let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + show_text(op, i, &mut state)?; } _ => {} } } carry.ts = ts; carry.stack = stack; - carry.marked = marked; - carry.layers = layers; + carry.marked = marked.regions; + carry.layers = marked.layers; positioned_tracking(&mut out); Ok(out) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 7543adeb39..c48522984d 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; -use super::*; +use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { /// The baseline's height in user space. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index dd58c72898..513077a027 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,11 +1,14 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, - source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, + source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; -use super::interp::{Carry, interpret}; +use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; -use super::*; +use super::{ + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse, + rewrite_styled_block, +}; /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] @@ -206,6 +209,532 @@ pub(crate) fn rewrite_block_with_limit( TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } +struct RewriteDispatch { + upright: bool, + use_styled_path: bool, +} + +fn choose_rewrite_path( + doc: &Document, + fonts: &Dict, + block: &TextBlock, + members: &[&TextLine], + first: &TextLine, + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let registered_face = fonts.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order by the single-style path. Styled regions do not + // reorder right-to-left runs. + let hebrew = text.unwrap_or(&block.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + // Turned text is rewritten in its own text space by the single-style path. Styled layout uses + // page x-coordinates and cannot safely apply those operations to rotated runs. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } + let visual_indic = members.iter().any(|line| line.visual_order); + let has_special_text = text.is_some_and(|draft| { + draft.contains(['\n', '\r']) + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() + }); + let has_multiple_runs = block.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color); + let source_requires_styled_path = + has_special_text || registered_face || text.is_some_and(|draft| source_program_completes(doc, fonts, first, draft)) || has_multiple_runs; + let explicit_styled_text = !style.ranges.is_empty() || style.resource.is_some(); + Ok(RewriteDispatch { + upright, + use_styled_path: upright + && !hebrew + && !visual_indic + && (explicit_styled_text || (source_requires_styled_path && style.family.is_none() && style.bold.is_none())), + }) +} + +/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed +/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are +/// breaks too; other white space is written as one space. +fn paragraph_text(text: &str) -> String { + text.replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect() +} + +/// Where a paragraph sits on its page, for choosing the width it is laid out in. +struct ParaGeometry { + rect: [f64; 4], + frame: [f64; 4], + crop: [f64; 4], + upright: bool, + /// The text-to-user scale. + k: f64, + /// The size in text space. + size: f64, + /// The requested horizontal move, in points. + dx: f64, + single_line: bool, + anchored_right: bool, + centred: bool, +} + +impl ParaGeometry { + /// A paragraph keeps its width (or takes the one asked for); a single line grows to the right + /// (to the left when right-to-left), up to the page's margin. + fn width(&self, requested: Option) -> Result { + let [left, _, right, _] = self.rect; + let own = (right - left).max(self.size * self.k); + let width = match requested { + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((self.size * self.k).min(14_400.0), 14_400.0), + Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !self.upright => (self.frame[2] - self.frame[0]).max(self.size * self.k), + None if self.anchored_right => own.max(right + self.dx - (self.crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if self.single_line && !self.centred => own.max(self.crop[2] - 36.0 - (left + self.dx)), + None => own, + }; + if width.is_finite() { Ok(width) } else { Err(EditError::Invalid("the paragraph's width must be a number".into())) } + } +} + +/// How far a baseline starting at `origin` and running along `direction` goes before it is 36 pt +/// from the page edge `low` or `high`. +fn margin_reach(origin: f64, direction: f64, low: f64, high: f64) -> f64 { + if direction > 1e-6 { + (high - 36.0 - origin) / direction + } else if direction < -1e-6 { + (low + 36.0 - origin) / direction + } else { + f64::INFINITY + } +} + +/// A turned single line grows to fit, up to where its baseline reaches the page's margin. +fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: f64, text: &str, advance: &Advance<'_>) -> f64 { + let (x0, y0) = (first.origin.x + offset[0], first.origin.baseline + offset[1]); + let (ux, uy) = first.direction(); + let tx = margin_reach(x0, ux, crop[0], crop[2]); + let ty = margin_reach(y0, uy, crop[1], crop[3]); + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(|line| advance.of(line)).fold(width, f64::max).min(cap) +} + +/// The widths of the paragraph's text in the font it will be written in, in user space. +struct Advance<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + family: crate::added::Family, + bold: bool, + italic: bool, + size: f64, + space: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + k: f64, +} + +impl Advance<'_> { + fn of(&self, s: &str) -> f64 { + let extra = s.chars().count() as f64 * self.char_spacing; + let t = match (self.source, self.type3) { + (Some(m), _) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, self.size, self.space, self.char_spacing, self.word_spacing) + } + (None, Some(fallback)) => { + // Each character's count in `s`, from one pass over `s`. + let mut counts: HashMap = HashMap::new(); + for c in s.chars() { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + } + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + } + (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, + }; + t * self.scale * self.k + } +} + +/// The font a rewritten paragraph is shown in, and how its text is encoded. +struct FontChoice { + show_font: String, + encode: Encoder, + /// The stand-in face to tell the caller about. + substituted: Option, +} + +/// What [`choose_font`] needs to know about the paragraph. +struct FontInputs<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + font_name: &'a str, + flat: &'a str, + source_base_font: &'a str, + family: crate::added::Family, + bold: bool, + italic: bool, + family_requested: bool, +} + +fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result { + if let Some(m) = inputs.source.cloned() { + let name = inputs.font_name.to_owned(); + return Ok(FontChoice { + show_font: name.clone(), + encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])), + substituted: None, + }); + } + if let Some(fallback) = inputs.type3.cloned() { + let substituted = Some(format!("{} Type3", fallback.family)); + let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + return Ok(FontChoice { show_font: first, encode: Box::new(move |s: &str| type3_encode(&fallback, s)), substituted }); + } + let flat = inputs.flat; + let win = pdfcraft_fonts::win_ansi(flat); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + let base = inputs.family.base_font(inputs.bold, inputs.italic); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", inputs.source_base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + // Named after its face, as the line and region rewrites name theirs: one name for every + // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). + let name = format!("PCEd{}", base.replace('-', "")); + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + let substituted = (!inputs.family_requested).then(|| base.to_string()); + let shown = name.clone(); + Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted }) +} + +/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken +/// back through the CTM's linear part. Checked before the fallback fonts are written: a refused +/// paragraph leaves the document as it was. +fn drawn_movement(ctm: [f64; 6], dx: f64, dy: f64) -> Result, EditError> { + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + let back = Matrix([ctm[0], ctm[1], ctm[2], ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; + Ok(Some(back.apply(dx, dy))) +} + +/// The paragraph's first text matrix, taken from where its text starts to where it is written. +fn emit_text_matrix(first: &Origin, emit: &Origin) -> Result<[f64; 6], EditError> { + if first.ctm == emit.ctm { + return Ok(first.tm); + } + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Ok(Matrix(first.tm).then(&Matrix(first.ctm)).then(&emit_inv).0) +} + +/// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. +struct LeadScale { + k: f64, + size: f64, + old_size: f64, + leading: f64, +} + +fn line_lead(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, scale: &LeadScale) -> f64 { + if let Some(m) = style.line_spacing { + return scale.size * m.clamp(0.5, 5.0); + } + // From the first two rows (a row may hold several runs). + let rows = block.rows.first().zip(block.rows.get(1)).and_then(|(upper, lower)| upper.first().zip(lower.first())); + if let Some((upper, lower)) = rows.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) { + return (upper.frame_origin().1 - lower.frame_origin().1) / scale.k * scale.size / scale.old_size; + } + if scale.leading > 0.0 { scale.leading } else { scale.size * 1.2 } +} + +/// The stroking twin of a fill colour operator. +fn stroke_twin(fill: &Op) -> Op { + let name = match fill.op.as_slice() { + b"g" => "G", + b"rg" => "RG", + b"k" => "K", + b"cs" => "CS", + b"sc" => "SC", + _ => "SCN", + }; + Op::new(name, fill.operands.clone()) +} + +/// Underlines in text space (the paragraph's text matrix), in its fill colour. +fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, f64)]) -> Vec { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![]), Op::new("cm", tm.iter().map(|v| n(*v)).collect())]; + ops.extend(fill.iter().map(stroke_twin)); + ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + ops.push(Op::new("m", vec![n(*x0), n(*y)])); + ops.push(Op::new("l", vec![n(*x1), n(*y)])); + } + ops.push(Op::new("S", vec![])); + ops.push(Op::new("Q", vec![])); + ops +} + +/// Alignment: each line's offset from the left edge. +struct Alignment { + align: Option, + width: f64, + k: f64, + anchored_right: bool, + right_edge: f64, + rtl: bool, +} + +impl Alignment { + /// `visible` is the width the line is aligned on, in user space; the offset is in text space. + fn offset(&self, visible: f64) -> f64 { + let free = (self.width - visible).max(0.0) / self.k; + match self.align { + Some(crate::added::Align::Center) => free / 2.0, + // A right-to-left line anchored at its right edge may start left of the old box. + Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, + Some(crate::added::Align::Right) => free, + // A justified right-to-left paragraph ends its last line at the right edge. + Some(crate::added::Align::Justify) if self.rtl => free, + _ => 0.0, + } + } +} + +/// Everything the wrapped lines of one paragraph are written with. +struct LineWriter<'a> { + advance: &'a Advance<'a>, + alignment: Alignment, + encode: &'a Encoder, + source: Option<&'a Metrics>, + justify: bool, + /// Whether `Tw` reaches every shown space. + word_spacing_reaches_spaces: bool, + width: f64, + scale: f64, + k: f64, + size: f64, + space: f64, + char_spacing: f64, + lead: f64, +} + +/// What carries over from one line to the next. +struct LineCursor { + x: f64, + tw_set: f64, + shown_font: String, +} + +impl LineWriter<'_> { + /// Justify: each line but the last fills the width. A stretched line shows only its words, as + /// before #4199: word spacing would reach the spaces before its first word and after its + /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't + /// justified. + fn stretch(&self, body: &str, last: bool) -> f64 { + let interior = body.matches(' ').count(); + if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) + } else { + 0.0 + } + } + + /// The text to show for a line and the width it is aligned on. Other lines show every space + /// typed. A left-to-right one is aligned on its width up to its last word; a right-to-left + /// one on its whole width, since the spaces typed after its last word are drawn on its left + /// and the ones before its first on its right. + fn shown<'t>(&self, line: &'t str, body: &'t str, tw: f64) -> (&'t str, f64) { + if tw > 0.0 { + (body, self.advance.of(body)) + } else if crate::bidi::rtl_base(line) { + (line, self.advance.of(line)) + } else { + (line, self.advance.of(line.trim_end_matches(' '))) + } + } + + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + let n = pdfcraft_content::num; + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = self.stretch(body, last); + let (shown, visible) = self.shown(line, body, tw); + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + if index > 0 || dx != 0.0 { + ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); + } + cursor.x = dx; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3), so where a space is + // shown with another code (a composite font, a generated Type 3 font, a subset without a + // space) the extra space follows each space as a TJ adjustment instead. + let stretch = if self.word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; + if tw != cursor.tw_set { + ops.push(Op::new("Tw", vec![n(tw)])); + cursor.tw_set = tw; + } + // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). + let visual = crate::bidi::to_visual(shown); + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } + } + let tw = tw + stretch; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * self.scale * self.k) / self.k; + Ok((dx, dx + w, -(index as f64) * self.lead - self.size * 0.12)) + } + + fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { + let n = pdfcraft_content::num; + if let Some(metrics) = self.source { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); + return Ok(()); + } + for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); + cursor.shown_font = font; + } + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + Ok(()) + } + + /// Writes every wrapped line, returning their underlines. + fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + } +} + +/// The operators that write a paragraph in its own graphics state, so a new colour (or anything +/// else) stops at the paragraph. +struct BlockPlan<'a> { + block: &'a TextBlock, + lines: &'a [TextLine], + style: &'a BlockStyle, + emit: &'a Origin, + after: &'a TextState, + movement: Option<(f64, f64)>, + state: TextState, + tm: [f64; 6], + size: f64, + underline: bool, +} + +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![])]; + ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); + // Moved: a translation inside that state. + if let Some((ax, ay)) = plan.movement { + ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + } + // Painted as the paragraph was, including what its text object set before the first show. + ops.extend(plan.state.paint_ops(plan.after)); + ops.push(Op::new("BT", vec![])); + let mut state = plan.state.clone(); + state.word_spacing = 0.0; + // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. + let mut cursor = LineCursor { x: 0.0, tw_set: 0.0, shown_font: show_font.clone() }; + state.font = Some((show_font, plan.size)); + if let Some([r, g, bl]) = plan.style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } + ops.extend(state.ops()); + ops.push(Op::new("Tm", plan.tm.iter().map(|v| n(*v)).collect())); + let underlines = writer.write_all(wrapped, &mut cursor, &mut ops)?; + ops.push(Op::new("ET", vec![])); + if plan.underline { + ops.extend(underline_ops(&state.fill, &plan.tm, plan.size, &underlines)); + } + ops.push(Op::new("Q", vec![])); + Ok(ops) +} + +/// Whether text shown earlier in the same text object stays first: the paragraph then goes where +/// its last row was, after any backgrounds between the rows, or between two halves of the text +/// object. The paragraph's own operators (and another drawing on the same baseline: a fake-bold +/// second copy, an invisible text layer) go, so the new text is all that may show. +fn shows_before_paragraph(ops: &[Op], from: usize, to: usize, dropped: &std::collections::HashSet) -> bool { + ops.get(from..to) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !dropped.contains(&(from + i))) +} + +/// A paragraph's single style, with the text-space values read from where it starts. +struct ParaStyle { + state: TextState, + size: f64, + old_size: f64, + k: f64, +} + +/// Spacing and scaling (text state), with the chosen values. +fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { + let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let mut state = o.state.clone(); + if let Some(c) = style.char_spacing { + state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + Ok((font_name, ParaStyle { state, size, old_size, k })) +} + +/// The font the paragraph's existing runs use, for reusing its glyphs. +struct SourceFont { + metrics: Option, + reuse: bool, + space: f64, +} + +fn source_font(doc: &Document, fonts_res: &Dict, font_name: &str, first: &TextLine, flat: &str, style: &BlockStyle) -> SourceFont { + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(flat) } else { flat.to_owned() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, fonts_res, m)); + SourceFont { metrics, reuse, space } +} + /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. pub(crate) fn rewrite_block_from( doc: &mut Document, @@ -228,86 +757,26 @@ pub(crate) fn rewrite_block_from( let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); - // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the - // single-style path below; the styled region path does not reorder right-to-left runs. - let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); - if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid( - "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), - )); - } - // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its - // own text space below; the styled region path lays rows out along the page's x axis. - let upright = members.iter().all(|line| line.upright()); - if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); - } - let visual_indic = members.iter().any(|line| line.visual_order); - if upright - && !hebrew - && !visual_indic - && (!style.ranges.is_empty() - || style.resource.is_some() - || (text.is_some_and(|draft| { - // A row break, `\n` or a carriage return, takes the path that keeps untouched rows. - draft.contains(['\n', '\r']) - || pdfcraft_fonts::source_shaping::required(draft) - || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() - }) || registered_face - || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) - || b.rows.iter().any(|row| row.len() > 1) - || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) - && style.family.is_none() - && style.bold.is_none()) - { + let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path { return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } + let upright = dispatch.upright; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is - // not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white - // space is written as one space. - let text: String = text - .unwrap_or(&b.text) - .replace("\r\n", "\n") - .chars() - .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', - c => c, - }) - .collect(); + let text = paragraph_text(text.unwrap_or(&b.text)); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; let last = members.last().copied().unwrap_or(first); let emit = &last.origin; - let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; - let k = o.k.max(1e-6); - // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); - // Spacing and scaling (text state), with the chosen values. - let mut ts_state = o.state.clone(); - if let Some(c) = style.char_spacing { - ts_state.char_spacing = c / k; - } - if let Some(sc) = style.scale { - ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); - } - let o_state = ts_state.clone(); - let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); - let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); + let reuse = source.reuse; // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. - let face = family.std14(bold, italic); - let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); @@ -327,287 +796,79 @@ pub(crate) fn rewrite_block_from( // Turned text is left alone: its centre lives along its own baseline, not in page space. let crop = p.crop(doc); let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); - // A paragraph keeps its width (or takes the one asked for); a single line grows to the right - // (to the left when right-to-left), up to the page's margin. - let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that - // (`clamp` panics when its minimum is the larger, or NaN). - Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), - Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), - // Turned text: the page's margins are along another axis. A single line grows (below). - None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (crop[0] + 36.0)), - // A centred line is not stretched to the margin: its own box is the frame it is centred in. - None if members.len() == 1 && !centred => (b.rect[2] - b.rect[0]).max(size * k).max(crop[2] - 36.0 - (b.rect[0] + dx)), - None => (b.rect[2] - b.rect[0]).max(size * k), - }; - if !width.is_finite() { - return Err(EditError::Invalid("the paragraph's width must be a number".into())); - } + let geometry = ParaGeometry { rect: b.rect, frame, crop, upright, k, size, dx, single_line: members.len() == 1, anchored_right, centred }; + let width = geometry.width(style.width)?; let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; // Where a right-aligned line ends, from the paragraph's left edge (user space). let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; - // The move and the width are checked here, before the fallback fonts are written: a refused - // paragraph leaves the document as it was. The move is in page space, so it is taken back through - // the CTM's linear part into the space the paragraph is drawn in. - let movement = if dx != 0.0 || dy != 0.0 { - let c = emit.ctm; - let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; - Some(back.apply(dx, dy)) - } else { - None - }; - let tm = if o.ctm == emit.ctm { - o.tm - } else { - let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 - }; + let movement = drawn_movement(emit.ctm, dx, dy)?; + let tm = emit_text_matrix(o, emit)?; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; - let advance = |s: &str| -> f64 { - let t = match (&metrics, reuse) { - (Some(m), true) => { - let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; - source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) - } - _ if let Some(fallback) = &type3 => { - // Each character's count in `s`, from one pass over `s`. - let mut counts: HashMap = HashMap::new(); - for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); - } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::() - + s.chars().count() as f64 * o_state.char_spacing - } - _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, - }; - t * o_state.scale * k + let source_metrics = source.metrics.as_ref().filter(|_| reuse); + let advance = Advance { + source: source_metrics, + type3: type3.as_ref(), + family, + bold, + italic, + size, + space: source.space, + char_spacing: o_state.char_spacing, + word_spacing: o_state.word_spacing, + scale: o_state.scale, + k, }; - // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - let crop = p.crop(doc); - let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); - let (ux, uy) = first.direction(); - let tx = if ux > 1e-6 { - (crop[2] - 36.0 - x0) / ux - } else if ux < -1e-6 { - (crop[0] + 36.0 - x0) / ux - } else { - f64::INFINITY - }; - let ty = if uy > 1e-6 { - (crop[3] - 36.0 - y0) / uy - } else if uy < -1e-6 { - (crop[1] + 36.0 - y0) / uy - } else { - f64::INFINITY - }; - let cap = width.max(tx.min(ty)).min(14_400.0); - text.split('\n').map(&advance).fold(width, f64::max).min(cap) + turned_line_width(first, crop, [dx, dy], width, &text, &advance) } else { width }; let right_edge = if anchored_right { right_edge } else { width }; - let wrapped = wrap(&text, width + 0.5, advance); - let mut substituted = None; - let new_font = !reuse; - let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) { - let name = font_name.clone(); - (font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)]))) - } else if let Some(fallback) = type3.clone() { - substituted = Some(format!("{} Type3", fallback.family)); - let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - (first, Box::new(move |s: &str| type3_encode(&fallback, s))) - } else { - let win = pdfcraft_fonts::win_ansi(&flat); - let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); - let base = family.base_font(bold, italic); - if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - // Named after its face, as the line and region rewrites name theirs: one name for every - // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). - let name = format!("PCEd{}", base.replace('-', "")); - fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); - if style.family.is_none() { - substituted = Some(base.to_string()); - } - let shown = name.clone(); - (name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))]))) - }; - // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = match style.line_spacing { - Some(m) => size * m.clamp(0.5, 5.0), - // From the first two rows (a row may hold several runs). - None if let Some((upper, lower)) = b - .rows - .first() - .zip(b.rows.get(1)) - .and_then(|(upper, lower)| upper.first().zip(lower.first())) - .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => - { - (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size - } - None if o_state.leading > 0.0 => o_state.leading, - None => size * 1.2, - }; - let n = pdfcraft_content::num; - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let drop = dropped; - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. + let wrapped = wrap(&text, width + 0.5, |s| advance.of(s)); + let font = choose_font( + &FontInputs { + source: source_metrics, + type3: type3.as_ref(), + font_name: &font_name, + flat: &flat, + source_base_font: &b.base_font, + family, + bold, + italic, + family_requested: style.family.is_some(), + }, + &mut fonts_res, + )?; + let lead = line_lead(&b, &lines, style, &LeadScale { k, size, old_size, leading: o_state.leading }); let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); + let split = shows_before_paragraph(&ops, emit.bt_op, start, &dropped); // What the text after the paragraph expects, and what is in force where the paragraph is // drawn: the state at its BT (or where it was split). let after = if split { &emit.state } else { &emit.bt_state }; - // In its own graphics state, so a new colour (or anything else) stops at the paragraph. - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); - // Moved: a translation inside that state. - if let Some((ax, ay)) = movement { - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); - } - // Painted as the paragraph was, including what its text object set before the first show. - block_ops.extend(o_state.paint_ops(after)); - block_ops.push(Op::new("BT", vec![])); - let mut state = o_state.clone(); - state.word_spacing = 0.0; - // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. - let mut shown_font = show_font.clone(); - state.font = Some((show_font, size)); - if let Some([r, g, bl]) = style.color { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; - } - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // Alignment: each line's offset from the left edge, in text space. - // `visible` is the width the line is aligned on (see the loop below). - let offset = |visible: f64| -> f64 { - let free = (width - visible).max(0.0) / k; - match align { - Some(crate::added::Align::Center) => free / 2.0, - // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k, - Some(crate::added::Align::Right) => free, - // A justified right-to-left paragraph ends its last line at the right edge. - Some(crate::added::Align::Justify) if rtl => free, - _ => 0.0, - } - }; - // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 - // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a - // generated Type 3 font, a subset without a space) the extra space follows each space as a - // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); - let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3); see `write_line`. + let word_spacing_reaches_spaces = match source_metrics { Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), None => type3.is_none(), }; - let mut x = 0.0; - let mut tw_set = 0.0; - let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { - // A stretched (justified) line shows only its words, as before #4199: word spacing would - // reach the spaces before its first word and after its last, so they are left out, and - // the line fills the width from the left edge. Nothing to stretch over, or a paragraph - // drawn at `0 Tz`, isn't justified. - let body = line.trim_matches(' '); - let interior = body.matches(' ').count(); - let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 { - Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite()) - } else { - None - } - .unwrap_or(0.0); - // Other lines show every space typed. A left-to-right one is aligned on its width up to - // its last word; a right-to-left one on its whole width, since the spaces typed after its - // last word are drawn on its left and the ones before its first on its right. - let (shown, visible) = if tw > 0.0 { - (body, advance(body)) - } else if crate::bidi::rtl_base(line) { - (line.as_str(), advance(line)) - } else { - (line.as_str(), advance(line.trim_end_matches(' '))) - }; - let dx = if tw > 0.0 { 0.0 } else { offset(visible) }; - if i > 0 || dx != 0.0 { - block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); - } - x = dx; - let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; - let tw = tw - stretch; - if tw != tw_set { - block_ops.push(Op::new("Tw", vec![n(tw)])); - tw_set = tw; - } - // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(shown); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; - } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - if stretch > 0.0 && piece.ends_with(' ') { - block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); - } - } - let tw = tw + stretch; - // Underlined to the line's last word, over its stretched gaps. - let w = (visible + tw * interior as f64 * o_state.scale * k) / k; - underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); - } - block_ops.push(Op::new("ET", vec![])); - if style.underline == Some(true) { - // In text space (the paragraph's text matrix), in its fill colour. - let colour: Vec = state - .fill - .iter() - .map(|f| match f.op.as_slice() { - b"g" => Op::new("G", f.operands.clone()), - b"rg" => Op::new("RG", f.operands.clone()), - b"k" => Op::new("K", f.operands.clone()), - b"cs" => Op::new("CS", f.operands.clone()), - b"sc" => Op::new("SC", f.operands.clone()), - _ => Op::new("SCN", f.operands.clone()), - }) - .collect(); - block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(colour); - block_ops.push(Op::new("w", vec![n(size * 0.06)])); - for (x0, x1, y) in underlines { - block_ops.push(Op::new("m", vec![n(x0), n(y)])); - block_ops.push(Op::new("l", vec![n(x1), n(y)])); - } - block_ops.push(Op::new("S", vec![])); - block_ops.push(Op::new("Q", vec![])); - } - block_ops.push(Op::new("Q", vec![])); + let writer = LineWriter { + advance: &advance, + alignment: Alignment { align, width, k, anchored_right, right_edge, rtl }, + encode: &font.encode, + source: source_metrics, + justify: align == Some(crate::added::Align::Justify), + word_spacing_reaches_spaces, + width, + scale: o_state.scale, + k, + size, + space: source.space, + char_spacing: o_state.char_spacing, + lead, + }; + let plan = + BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); @@ -617,9 +878,10 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !drop.contains(&i)) + (insert, !dropped.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); + let new_font = !reuse; if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } @@ -630,7 +892,7 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; - Ok(LineEdit { substituted }) + Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 7516cb793b..be5b4dffa6 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -1,6 +1,11 @@ //! Geometry-based regions and mixed-style rewriting of existing text. -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, + source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, +}; #[path = "text_regions/grouping.rs"] mod grouping; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 29004b8e33..b8a5abca1e 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 09baf1ac7e..e8b3b683d0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,5 +1,10 @@ use super::grouping::{across, is_centred, row_rect}; -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, + splice, type3_faces, type3_glyph, write_type3_glyphs, +}; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. // A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). @@ -13,6 +18,526 @@ struct FallbackGroup { positions: Vec, } +struct FallbackBuilder { + groups: Vec, + names: std::collections::BTreeMap<(String, bool, char), usize>, + glyphs: usize, + per_font: usize, + budget: usize, +} + +struct FontResolver<'a> { + doc: &'a mut Document, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + full_faces: HashMap, + programs: HashMap>>>, +} + +impl FontResolver<'_> { + fn encodable_font(&mut self, source: &TextLine, text: &str, requested: Option<&str>) -> Result, EditError> { + if let Some(resource) = requested { + if !self.metrics.get(resource).is_some_and(|metrics| source_encodes(metrics, text)) { + return Err(EditError::Invalid(format!( + "the selected font resource {resource} cannot show U+{:04X}", + text.chars().next().map_or(0, u32::from) + ))); + } + return Ok(Some(resource.to_owned())); + } + if self.metrics.get(&source.font).is_some_and(|metrics| source_encodes(metrics, text)) { + return Ok(Some(source.font.clone())); + } + if let Some((name, _)) = self.metrics.iter().find(|(_, metrics)| { + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) + && source_encodes(metrics, text) + }) { + return Ok(Some(name.clone())); + } + if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|face| pdfcraft_fonts::embedding::covers(face.bytes, text)) + { + return self.embed_face(face.name(), face.bytes, face.family, "the full source font could not be loaded").map(Some); + } + let Some(bytes) = self + .programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(self.doc, self.fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, text)) + else { + return Ok(None); + }; + let key = format!("source {}", source.font); + if let Some(name) = self.full_faces.get(&key) { + return Ok(Some(name.clone())); + } + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + self.embed_face(key, &bytes, &family, "the source font could not be loaded").map(Some) + } + + fn embed_face(&mut self, key: String, bytes: &[u8], family: &str, missing_message: &str) -> Result { + if let Some(name) = self.full_faces.get(&key) { + return Ok(name.clone()); + } + let name = pdfcraft_fonts::embedding::embed(self.doc, self.fonts, bytes, family).map_err(EditError::Invalid)?; + let font = + self.fonts.get(name.as_bytes()).and_then(|resource| self.doc.dict(resource)).ok_or_else(|| EditError::Invalid(missing_message.into()))?; + self.metrics.insert(name.clone(), Metrics::from_dict(self.doc, &font)); + self.full_faces.insert(key, name.clone()); + Ok(name) + } + + fn substitute_font(&mut self, source: &TextLine) -> String { + let family = source_family(&source.base_font); + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + self.metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(self.doc, &font)); + self.fonts.set(name.clone().into_bytes(), Object::Dict(font)); + name + } +} + +struct PreparedCharacters { + values: Vec, + substituted: Option, +} + +struct CharacterPreparation<'a> { + doc: &'a mut Document, + first: &'a TextLine, + lines: &'a [TextLine], + draft: &'a str, + spans: Vec<(std::ops::Range, usize)>, + style: &'a BlockStyle, + overrides: &'a HashMap, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + max_glyphs: usize, +} + +impl CharacterPreparation<'_> { + fn prepare(self) -> Result { + let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback = FallbackBuilder::new(max_glyphs); + let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; + for (range, index) in spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let text = ch.to_string(); + let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); + let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + name + } else if needs_type3(&text) { + fallback.prepare(source, ch, &text, chars.len())? + } else { + let name = resolver.substitute_font(source); + substituted = Some(source_family(&source.base_font).base_font(source.bold, source.italic).to_owned()); + name + }; + chars.push((ch, index, name)); + } + } + drop(resolver); + for group in fallback.groups { + let written = write_type3_glyphs(doc, fonts, group.face, false, group.glyphs, fallback.per_font); + for name in &written.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|resource| doc.dict(resource)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = written.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = written.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(written.family.to_owned()); + } + Ok(PreparedCharacters { values: chars, substituted }) + } +} + +impl FallbackBuilder { + fn new(max_glyphs: usize) -> Self { + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + Self { + groups: Vec::new(), + names: std::collections::BTreeMap::new(), + glyphs: 0, + per_font, + budget: per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES), + } + } + + fn prepare(&mut self, source: &TextLine, ch: char, text: &str, position: usize) -> Result { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = self.names.get(&key) { + *index + } else { + let (faces, _) = type3_faces(text, source_family(&source.base_font), source.bold)?; + let face = fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(text))?; + let index = self.groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = self.groups.len(); + self.groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + self.names.insert(key, index); + index + }; + let group = self.groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if self.glyphs >= self.budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {} fallback glyphs; edit a smaller paragraph or load a complete font", + self.budget + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % self.per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + self.glyphs = self.glyphs.saturating_add(1); + } + group.positions.push(position); + Ok(String::new()) + } +} + +struct StyledLines<'a> { + lines: Vec, + spans: Vec<(std::ops::Range, usize)>, + underlines: HashMap, + overrides: HashMap, +} + +type StyledChar = (char, usize, String); +type StyledRows = Vec>; +type RowRanges = Vec>; +type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; + +struct ShapedRows { + runs: ShapedRunCache, + advances: HashMap<(usize, usize), f64>, +} + +fn prepare_shaped_rows( + doc: &Document, + fonts: &Dict, + rows: &[Vec], + kept: &[bool], + advance: impl Fn(&StyledChar) -> f64, +) -> Result { + let mut runs = HashMap::new(); + let mut advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { + end += 1; + } + let segment = row.get(at..end).unwrap_or_default(); + advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let text: String = segment.iter().map(|character| character.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { + runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + Ok(ShapedRows { runs, advances }) +} + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { + let mut resources = HashMap::new(); + for (key, (bytes, shaped, family)) in runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + resources.insert(*key, resource); + } + Ok(resources) +} + +fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { + for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { + let text: String = row.iter().map(|character| character.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) + && row.first().is_some_and(|first| row.iter().any(|character| character.1 != first.1 || character.2 != first.2)) + { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + Ok(()) +} + +fn calibration_factors( + lines: &[TextLine], + metrics: &std::collections::BTreeMap, + style: &BlockStyle, + overrides: &HashMap, + space_of: impl Fn(&str, &Metrics) -> f64, +) -> HashMap { + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(metrics) = metrics.get(&source.font) { + let nominal = source_width( + metrics, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, size)| *size), + space_of(&source.font, metrics), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = + style.resource.is_some() || overrides.get(&index).is_some_and(|range| range.family.is_some() || range.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + calibration +} + +fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|range| range.range.start); + let invalid_range = ranges.iter().any(|range| { + range.range.start >= range.range.end + || draft.get(range.range.clone()).is_none() + || [range.size, range.char_spacing, range.scale].iter().flatten().any(|value| !value.is_finite()) + || range.size.is_some_and(|value| value <= 0.0 || value > 14_400.0) + || range.color.is_some_and(|color| color.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) + }); + if ranges.len() > 1024 || invalid_range || ranges.windows(2).any(|pair| pair[0].range.end > pair[1].range.start) { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + Ok(ranges) +} + +fn replacement_text<'a>(block: &'a TextBlock, text: Option<&'a str>) -> Result<&'a str, EditError> { + let draft = match text { + Some(text) if text != block.text => text, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + Ok(draft) +} + +fn has_no_effective_style_change(block: &TextBlock, draft: &str, style: &BlockStyle) -> bool { + draft == block.editor_text && *style == BlockStyle::default() +} + +fn page_font_metrics( + doc: &Document, + resource_dict: &Dict, + block: &TextBlock, + lines: &[TextLine], +) -> (Dict, std::collections::BTreeMap) { + let mut fonts = resource_dict.get(b"Font").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(font)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|resource| doc.resolve(resource).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + (fonts, metrics) +} + +fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextBlock, style: &BlockStyle) -> Result<(f64, f64, f64), EditError> { + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|value| !value.is_finite()) + || style.color.is_some_and(|color| color.iter().any(|value| !value.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|width| !width.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let natural = if block.invisible || block.rows.len() == 1 { + (page.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) +} + +fn wrap_styled_rows( + chars: &[StyledChar], + width: f64, + measure: impl Fn(&[StyledChar]) -> Result, +) -> Result<(StyledRows, RowRanges), EditError> { + let mut rows: Vec> = Vec::new(); + let mut row_ranges = Vec::new(); + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) + && !current.0.is_whitespace() + && !crate::linebreak::between(before.0, current.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let word_width = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { + row.pop(); + } + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += word_width; + at = end; + } + if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); + rows.push(row); + } + Ok((rows, row_ranges)) +} + +fn apply_range_styles<'a>( + first: &TextLine, + block: &TextBlock, + lines: &[TextLine], + draft: &str, + ranges: &[&'a RangeStyle], + style: &BlockStyle, +) -> StyledLines<'a> { + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for range in ranges { + for edge in [range.range.start, range.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let selected = ranges.iter().find(|range| range.range.contains(&start)).copied(); + let mut index = source; + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let range = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + range.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &range.resource { + line.font = resource.clone(); + } + if let Some(size) = range.size { + line.size = size; + } + if let Some(color) = range.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = range.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = range.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(range) = selected { + overrides.insert(index, range); + } + if let Some(underline) = range.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, @@ -84,6 +609,48 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +struct RewriteAnchor<'a> { + lead: &'a TextLine, + drop: std::collections::HashSet, + start: usize, + split: bool, +} + +fn rewrite_anchor<'a>( + block: &TextBlock, + lines: &'a [TextLine], + first: &'a TextLine, + ops: &[Op], + kept_lines: &std::collections::HashSet, + kept: &mut [bool], +) -> RewriteAnchor<'a> { + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|index| !kept_lines.contains(index)).filter_map(|index| lines.get(*index)).collect(); + let lead = rewritten + .last() + .copied() + .filter(|_| !rewritten.iter().flat_map(|line| &line.ops).any(|index| ops.get(*index).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|value| *value = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|index| lines.get(*index)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) + } + }; + let origin = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|line| line.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(origin.bt_op); + let split = ops + .get(origin.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(index, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(origin.bt_op + index))); + RewriteAnchor { lead, drop, start, split } +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -96,291 +663,36 @@ pub(crate) fn rewrite_styled_block( max_glyphs: usize, ) -> Result { let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let draft = match text { - Some(t) if t != block.text => t, - _ => &block.editor_text, - }; - if draft.len() > 4_000_000 { - return Err(EditError::Invalid("the replacement text is too large".into())); - } - if draft == block.editor_text && *style == BlockStyle::default() { + let draft = replacement_text(block, text)?; + if has_no_effective_style_change(block, draft, style) { return Ok(LineEdit { substituted: None }); } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) - { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); - } + let ranges = validated_ranges(draft, style)?; let source_count = lines.len(); - let mut styled_lines = lines.to_vec(); - let mut styled_spans = Vec::new(); - let mut underlines = HashMap::new(); - let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { - let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { - if span.contains(&edge) { - edges.push(edge); - } - } - } - edges.sort_unstable(); - edges.dedup(); - for bounds in edges.windows(2) { - let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; - let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); - if selected.is_some() || style.family.is_some() || style.bold.is_some() { - let default_range = RangeStyle { - range: start..end, - family: None, - resource: None, - size: None, - color: None, - underline: None, - char_spacing: None, - scale: None, - }; - let r = selected.unwrap_or(&default_range); - let mut line = lines.get(source).unwrap_or(first).clone(); - if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) - { - let base = family.base_font(bold, italic); - line.font = format!("PCRange{}", base.replace('-', "")); - line.base_font = base.to_owned(); - line.bold = bold; - line.italic = italic; - } - if let Some(resource) = &r.resource { - line.font = resource.clone(); - } - if let Some(size) = r.size { - line.size = size; - } - if let Some(color) = r.color { - line.color = color; - line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; - } - if let Some(spacing) = r.char_spacing { - line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); - } - if let Some(scale) = r.scale { - line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); - } - index = styled_lines.len(); - styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); - } - if let Some(underline) = r.underline { - underlines.insert(index, underline); - } - } - styled_spans.push((start..end, index)); - } - } - let lines = styled_lines.as_slice(); + let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let lines = styled.lines.as_slice(); + let styled_spans = styled.spans; + let underlines = styled.underlines; + let overrides = styled.overrides; let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } - } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) - { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); - } - } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] - }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_groups: Vec = Vec::new(); - let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); - let mut fallback_glyphs = 0_usize; - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - let start = range.start; - for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - // A carriage return breaks the row too. Before `\n` the pair is one break, so the - // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's - // characters, which rows are counted in. - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); - chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.clone(), source.bold, ch); - let group_index = if let Some(index) = fallback_names.get(&key) { - *index - } else { - // Keep the old per-character face choice: a serif Japanese character - // and a Cyrillic character may need different faces even in one style. - // Hebrew uses the separate RTL path before reaching this writer. - let (faces, _) = type3_faces(&s, family, source.bold)?; - let face = - fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; - let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { - let index = fallback_groups.len(); - fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); - index - }); - fallback_names.insert(key, index); - index - }; - let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; - if group.seen.insert(ch) { - if fallback_glyphs >= fallback_budget { - return Err(EditError::Invalid(format!( - "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" - ))); - } - let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; - let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - group.glyphs.push(Type3Glyph { code, ch, path, width }); - fallback_glyphs = fallback_glyphs.saturating_add(1); - } - group.positions.push(chars.len()); - // Filled from the prepared font/code map before measuring or drawing. - String::new() - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); - } - } - // All fallback outlines have now succeeded. Batch by actual face, preserving source - // style indices; the existing writer then coalesces adjacent equal-font/style characters. - for group in fallback_groups { - let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); - for name in &fallback.names { - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - } - for position in group.positions { - let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; - let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; - item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; - } - substituted = Some(fallback.family.to_owned()); + let mut res = p.dict.get(b"Resources").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + let (mut fonts, mut metrics) = page_font_metrics(doc, &res, block, lines); + let (dx, dy, width) = paragraph_geometry(doc, &p, block, style)?; + let prepared = CharacterPreparation { + doc, + first, + lines, + draft, + spans: styled_spans, + style, + overrides: &overrides, + fonts: &mut fonts, + metrics: &mut metrics, + max_glyphs, } + .prepare()?; + let chars = prepared.values; + let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; @@ -400,23 +712,7 @@ pub(crate) fn rewrite_styled_block( spaces.borrow_mut().insert(name.to_owned(), w); w }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } + let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); let advance = |item: &(char, usize, String)| -> f64 { let source = lines.get(item.1).unwrap_or(first); let Some(m) = metrics.get(&item.2) else { return 0.0 }; @@ -466,58 +762,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - // The CR of a CRLF: the `\n` after it breaks the row. - if item.0 == '\r' { - at += 1; - continue; - } - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } + let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -531,65 +776,18 @@ pub(crate) fn rewrite_styled_block( }; let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; + // The rewrite is placed after the last rewritten row unless a text-show operator there + // would make a kept following row depend on that position. + let anchor = rewrite_anchor(block, lines, first, &ops, &kept_lines, &mut kept); + let RewriteAnchor { lead, drop, start, split } = anchor; let o = &lead.origin; - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } + validate_contextual_rows(&rows, &kept)?; + let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; + let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs index 893a0e4d24..fe1aa78688 100644 --- a/crates/edit/src/text_session.rs +++ b/crates/edit/src/text_session.rs @@ -3,7 +3,10 @@ //! another one's output; the commit writes into the document it was opened on, and refuses if //! that document has changed since. -use super::*; +use super::{ + BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from, + text_lines, +}; /// One paragraph being edited: the document as it was opened, and the page's lines then. pub struct TextSession { From 166f706e3e73ae69ef15931b467d1c87bcbe5e98 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 061/120] Refactor engine for strict Clippy checks --- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/edit/labels.rs | 2 +- crates/engine/src/edit/run.rs | 5 +- crates/engine/src/session/editing.rs | 9 +- crates/engine/src/session/files.rs | 6 +- crates/engine/src/session/save.rs | 5 +- crates/engine/src/session/view.rs | 5 +- crates/engine/src/tests.rs | 149 +++++++++++++++------------ 8 files changed, 110 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d25cc65037..d28429de6f 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5e25ae465b..ea25898068 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table}; /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7901a3b7d8..7bf5467fcf 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,4 +1,7 @@ -use super::*; +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, +}; /// 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> { diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 9b1a8340a5..f8d5648a2d 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -1,4 +1,11 @@ -use super::*; +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings, + render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, + write_incremental, xfa, xfa_sync_datasets, +}; impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 6d4e11d390..1e8cd9b08a 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 23994aee37..d4156a2eb1 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard, + render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index cef539189a..055437f562 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; impl Session { pub fn new() -> Self { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e7e68a7588..14a61b2633 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -8106,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; @@ -8124,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8720,69 +8737,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}"); + } +} From 5e75b9c3e6c48e44da1d1e2223d3115ce9416a01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 062/120] Refactor export for strict Clippy checks --- crates/export/src/as_docx.rs | 2 +- crates/export/src/as_rtf.rs | 2 +- crates/export/src/layout.rs | 2 +- crates/export/src/lib.rs | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index d8fb84f7d4..ab7451dc0e 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ +use super::Zip; use super::as_html::esc; use super::layout::{Image, Item, Page, Table, body_size, items}; -use super::*; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs index d7495df135..61585d82fb 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -1,7 +1,7 @@ use super::as_docx::rgb8; use super::as_html::allowed; use super::layout::{Block, Item, Page, count_lookup_steps, items}; -use super::*; +use std::collections::HashMap; fn rtf_text(s: &str) -> String { let mut o = String::new(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 8a06050898..d1d074ac61 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use std::collections::HashMap; /// A paragraph of a page. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index cf32ea7930..5275b02439 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -10,8 +10,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; - mod pptx; mod zip; From 4965e23bfe4f2b0e37f5d978654e20594885e538 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 063/120] Refactor forms for strict Clippy checks --- crates/forms/src/af/script.rs | 2 +- crates/forms/src/appearances.rs | 2 +- crates/forms/src/author/edit.rs | 231 +++++++++++++++------------ crates/forms/src/author/lifecycle.rs | 5 +- crates/forms/src/author/look.rs | 2 +- crates/forms/src/author/tree.rs | 2 +- crates/forms/src/read.rs | 2 +- crates/forms/src/tab_order.rs | 2 +- crates/forms/src/tests.rs | 203 +++++++++++++---------- crates/forms/src/values.rs | 5 +- 10 files changed, 263 insertions(+), 193 deletions(-) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index c807f2d7d7..9aed297b45 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,6 +1,6 @@ use super::calc::{js_tokens, notation_js, notation_range}; use super::format::{DATE_PRESETS, TIME_PRESETS}; -use super::*; +use super::{CalcOp, Calculate, Format, Validate}; // ── parsing scripts ───────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index d035feb4ef..64569fa3ca 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -1,6 +1,6 @@ use super::read::{acroform, fields}; use super::values::merged_appearance; -use super::*; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4fc..d61cfe28f2 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,6 +1,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; -use super::*; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; /// Swap width and height, keeping the center. `None` when that is not a usable widget. fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { @@ -70,59 +70,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } + let new_name = rename_field(doc, &all, &f, name, props)?; // The new default appearance when the size or the look changes. let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); let new_da = match &props.look { @@ -248,51 +196,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } + apply_widget_style_props(doc, &f, name, props)?; if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); @@ -341,6 +245,127 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result { + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok(new_name) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -348,7 +373,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Dict { let mut d = Dict::new(); diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 6044bdc70b..23661ed70a 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e1..d8ca67ffb7 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. pub(crate) fn ensure_form(doc: &mut Document) -> Result { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index a962af91bd..e689c0e813 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -1,5 +1,5 @@ use super::tab_order::rank_tabs; -use super::*; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 9c5c33b694..77234ee094 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -1,5 +1,5 @@ use super::read::{fields, page_refs}; -use super::*; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..d8199f15d3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a70..37df5502d1 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,6 +1,9 @@ use super::appearances::{declares_pdfa, need_appearances}; use super::read::{acroform, field_flags, fields, page_refs}; -use super::*; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, + calc_order, flags, invalid, name_obj, scripting, +}; // ── writing ───────────────────────────────────────────────────────────────────────────────── From a0598816d2c9fd043b9c3d8fec22e8f44bae720e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 064/120] Refactor js for strict Clippy checks --- crates/js/src/document.rs | 2 +- crates/js/src/format.rs | 2 +- crates/js/src/formcalc/access.rs | 5 +- crates/js/src/formcalc/ast.rs | 2 +- crates/js/src/formcalc/builtins.rs | 67 +++++++---- crates/js/src/formcalc/eval.rs | 5 +- crates/js/src/formcalc/lexer.rs | 2 +- crates/js/src/formcalc/parser.rs | 2 +- crates/js/src/globals.rs | 2 +- crates/js/src/host.rs | 5 +- crates/js/src/run.rs | 2 +- crates/js/src/tests.rs | 173 ++++++++++++++++++++--------- crates/js/src/xfa/globals.rs | 5 +- crates/js/src/xfa/host.rs | 2 +- crates/js/src/xfa/instances.rs | 2 +- crates/js/src/xfa/model.rs | 2 +- crates/js/src/xfa/nodes.rs | 5 +- crates/js/src/xfa/run.rs | 2 +- 18 files changed, 201 insertions(+), 86 deletions(-) diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 3d0fe194ee..e977457e2b 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -1,6 +1,6 @@ use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 5f2eed1021..7f207b1916 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -1,6 +1,6 @@ use super::host::{arg, error, s, text}; use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; -use super::*; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; /// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups /// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 3911a5223f..bffbd8823d 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Idx}; use super::value::{Val, num_text, parse_num}; -use super::*; +use super::{ + CLOCK_EVERY, HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, + instances, resolve_som, +}; impl Interp<'_> { pub(super) fn step(&mut self) -> R<()> { diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs index f9a88be798..ab453a8061 100644 --- a/crates/js/src/formcalc/ast.rs +++ b/crates/js/src/formcalc/ast.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Func, Rc}; // ── syntax ────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index 1e7bb94245..c3b7c7f1f2 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; use super::words::word_num; -use super::*; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { /// A built-in function: its result is never a string past [`MAX_STRING`], and work on @@ -22,6 +22,15 @@ impl Interp<'_> { fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { let lower = name.to_ascii_lowercase(); let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } Ok(match lower.as_str() { // ── arithmetic "abs" => return n(self.arg_num(args, 0)?.abs()), @@ -271,7 +280,24 @@ impl Interp<'_> { let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); parse_num(&cleaned).map_or(Val::Null, Val::Num) } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "date" => { #[cfg(not(target_arch = "wasm32"))] { @@ -304,7 +330,7 @@ impl Interp<'_> { let days = self.arg_num(args, 0)?; let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let (y, m, d) = civil_from_days(days as i64); Val::Str(format_date(y, m, d, &pic)) @@ -326,7 +352,7 @@ impl Interp<'_> { "num2time" | "num2gmtime" => { let ms = self.arg_num(args, 0)?; if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let s = (ms / 1000.0) as u64; Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) @@ -344,7 +370,13 @@ impl Interp<'_> { parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) } - // ── financial + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "fv" => { let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) @@ -428,7 +460,13 @@ impl Interp<'_> { } Val::Num((lo + hi) / 2.0 * 12.0) } - // ── units and references + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "unittype" => { let s = self.arg_str(args, 0)?; let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); @@ -459,19 +497,8 @@ impl Interp<'_> { let target = if to.trim().is_empty() { unit.clone() } else { to }; Val::Num(v / per_in(&unit) * per_in(&target)) } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) + _ => return Ok(None), + }; + Ok(Some(value)) } } diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 23ce3ec0c1..a3218a78f6 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Stmt}; use super::value::{Val, is_numeric_text, num_text}; -use super::*; +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, +}; impl Interp<'_> { // ── expressions diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index ba9ae4f16a..46af19f761 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::MAX_STRING; // ── lexer ─────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index 84cf121cde..fceedadf5f 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -1,6 +1,6 @@ use super::ast::{Expr, Idx, Stmt}; use super::lexer::{Tok, Token, lex}; -use super::*; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; struct Parser { toks: Vec, diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index dd487c2482..a9ad4d2b8f 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -2,7 +2,7 @@ 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::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; // ── app, util, console ────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 131b8851c9..6e442d2a2b 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -1,6 +1,9 @@ use super::model::{DocInfo, FieldState, FieldType, Request}; use super::run::MAX_ARRAY_LENGTH; -use super::*; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, + xfa, +}; pub(crate) struct Host { pub(crate) fields: Arc>, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 17b5c252ff..07c0e984bb 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -1,7 +1,7 @@ use super::globals::{event_object, install}; use super::host::{Host, Shared, text}; use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; -use super::*; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e3..38cebf54ce 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1017,6 +1017,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1042,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1061,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1081,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1092,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1147,6 +1167,13 @@ s"# #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1197,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1208,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1215,7 +1247,9 @@ table._row.count"#, XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#, #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); + 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()); - let o = run("qty = Space(100000)"); + 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); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + 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(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 2507941658..54508e6cab 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -2,7 +2,10 @@ use super::host::host; use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; -use super::*; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index 419d2141fc..ea2d4d4332 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::*; +use super::{Context, HashMap, JsResult, RefCell, error}; /// One node of the flattened form, shared by the JavaScript and FormCalc engines. pub(crate) struct HNode { diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs index b03e6aef09..c099c5abe8 100644 --- a/crates/js/src/xfa/instances.rs +++ b/crates/js/src/xfa/instances.rs @@ -1,7 +1,7 @@ use super::host::{HNode, XHost, host}; use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; use super::nodes::{instances, node_object}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index b9c6cedaae..83b2d7d149 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashSet; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs index b9483f25d1..75943352e6 100644 --- a/crates/js/src/xfa/nodes.rs +++ b/crates/js/src/xfa/nodes.rs @@ -2,7 +2,10 @@ use super::globals::{recalculate, relayout}; use super::host::{HNode, XHost, host}; use super::instances::{instance_manager, list_object}; use super::model::{MAX_DEPTH, XfaEffect}; -use super::*; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; // ── node objects ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs index 2db77d7062..f951e02a54 100644 --- a/crates/js/src/xfa/run.rs +++ b/crates/js/src/xfa/run.rs @@ -1,7 +1,7 @@ use super::globals::install; use super::host::{Shared, XHost}; use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; -use super::*; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; /// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { From f40a57878b993cea1bb5574c627af1bed921d225 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 065/120] Refactor organize for strict Clippy checks --- crates/organize/src/import/carry.rs | 2 +- crates/organize/src/import/combine.rs | 2 +- crates/organize/src/import/copy.rs | 2 +- crates/organize/src/import/fields.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 21c21b9be3..15a412e231 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -1,5 +1,5 @@ use super::copy::Copier; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 09a1b0725a..06972ae101 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -1,5 +1,5 @@ use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; -use super::*; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f78411806f..f4b5499625 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 98f69c69ec..44910d0406 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -1,5 +1,5 @@ use super::copy::WIDGET_KEYS; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; /// Rename the top-level fields about to be registered in `dst` whose fully-qualified names /// (`parent.T.child`) are already taken, so each imported field stays its own field instead of From 530e2a26450541654b1e0a951e01e50eac180c6f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 066/120] Refactor pdfcraft-cli for strict Clippy checks --- apps/pdfcraft-cli/src/check.rs | 4 +++- apps/pdfcraft-cli/src/commands.rs | 2 +- apps/pdfcraft-cli/src/options.rs | 2 +- apps/pdfcraft-cli/src/run.rs | 4 +++- apps/pdfcraft-cli/src/ui.rs | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 1821f85826..62993ad6dc 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -1,6 +1,8 @@ use super::commands::{parse_dpi, read}; use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; /// Audit a document for faux redactions. Prints the findings as JSON; fails when any are /// found so scripts and CI can gate on it, and when the audit itself cannot run. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 5efec8331c..9d0b2057f0 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -2,7 +2,7 @@ use super::options::{ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, validate_options, }; -use super::*; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs index ab1a3db34b..1ddbfabcd5 100644 --- a/apps/pdfcraft-cli/src/options.rs +++ b/apps/pdfcraft-cli/src/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::CliError; pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs index 19eed21876..0726087d9d 100644 --- a/apps/pdfcraft-cli/src/run.rs +++ b/apps/pdfcraft-cli/src/run.rs @@ -1,7 +1,9 @@ #[cfg(feature = "mcp")] use super::options::MCP_OPTIONS; use super::options::{RUN_OPTIONS, flag, positional, validate_options}; -use super::*; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; // ---- automation -------------------------------------------------------------------------------- diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index 9d12233528..dc419171b0 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -1,5 +1,5 @@ use super::options::{UI_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{CliError, Duration, stdout_line}; // ---- UI control channel client ----------------------------------------------------------------- From c043c82531526d976762fb0ca9e0e16ea5b6e05e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 067/120] Refactor preflight for strict Clippy checks --- crates/preflight/src/lib.rs | 234 ++++++++++++++++++++++-------------- 1 file changed, 147 insertions(+), 87 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e3..3fc6424808 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,99 +514,159 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { - let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } - } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } - } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } - } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } - } - } + check_objects(doc, level, &objs, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); out } +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { + let Some(d) = dict_of(o) else { continue }; + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) { + check_annotation(doc, dict, subtype, page_of(reference), issue); + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); + } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); + } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); + } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); + } + Level::A3b => {} + } +} + fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { let sub = d.name(b"Subtype"); if sub == Some(b"Type3") { From bc89733725c41f8373d28c1595370221362ea7ad Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 068/120] Refactor print for strict Clippy checks --- crates/print/src/imposition.rs | 2 +- crates/print/src/settings.rs | 2 +- crates/print/src/sheets.rs | 61 +++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 1257bcf25e..aa9a501f66 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::settings::{Content, Settings}; use super::sheets::{Sheet, layout}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; // Model-derived transforms and clipping bounds need their full PDF decimal precision. fn geometry_number(value: f64) -> String { diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 7c9554f25e..0506eb5b18 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::spool; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ce80adc3eb..061a6ed5c3 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::imposition::decoded_contents; use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects @@ -162,32 +162,7 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } } Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); @@ -348,3 +323,35 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } Ok(sheets) } + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} From 33e23da8a92b0d344bc93e2e6cef180520298b88 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 069/120] Refactor redact for strict Clippy checks --- crates/redact/src/interp.rs | 552 ++++++++++++++++------------- crates/redact/src/interp/layers.rs | 2 +- crates/redact/src/interp/prune.rs | 5 +- crates/redact/src/lib.rs | 398 +++++++++++---------- crates/redact/src/type3.rs | 64 ++-- 5 files changed, 568 insertions(+), 453 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 71b4684bc7..3baec29c2c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -242,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -277,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -292,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); - } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; - } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; - } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; - } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; - } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; - } - } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } - } - _ => {} - } - ops.push(op); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), } - if !path.is_empty() { - ops.append(&mut path); + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; } - processed.push((ops, changed)); - } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); - } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) } - bytes - })); + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); + } + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + } + } + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; + } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; + } + } + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None + } + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs index 32d7afdf1e..88d1c4e677 100644 --- a/crates/redact/src/interp/layers.rs +++ b/crates/redact/src/interp/layers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; impl<'a> Scope<'a> { /// Is this optional-content reference (an OCG or OCMD) hidden? diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2708a87a42..6ca5ea2803 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, /// so a full-rewrite save stops reaching the original image or form it named. A name that some diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d89..de9dafe7db 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab902..57971571af 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); - - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { From a11909551d5027cc6b509404a94cebc1e8f7f075 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 070/120] Refactor render for strict Clippy checks --- crates/render/src/inspect/annotations.rs | 2 +- crates/render/src/inspect/core.rs | 5 ++++- crates/render/src/inspect/model.rs | 2 +- crates/render/src/inspect/navigation.rs | 5 ++++- crates/render/src/inspect/resources.rs | 2 +- crates/render/src/raster/pool.rs | 6 +++++- crates/render/src/text/device.rs | 5 ++++- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/order.rs | 2 +- 9 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 07283c0473..603aa88110 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,7 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; use super::navigation::text_string; -use super::*; +use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 875d873654..c190dc565b 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,7 +1,10 @@ use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; -use super::*; +use super::{ + Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, + ObjectId, OnceCell, OpenError, Pdf, Structure, catch_unwind, +}; /// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is /// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index e5f09c9242..490897b60f 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashMap; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 173217c93b..4000a74135 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -2,7 +2,10 @@ use super::core::inspect; use super::core::spend; use super::model::{DestView, OutlineItem, PageInfo}; -use super::*; +use super::{ + BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, + MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, +}; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document /// reads only this window; the rest are listed by page through `pdfcraft_organize`. diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0386c5781f..0895c64f8a 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -3,7 +3,7 @@ use super::core::inspect; use super::core::{Inspector, load_options}; use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; use super::navigation::NameTreeSeen; -use super::*; +use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to /// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. diff --git a/crates/render/src/raster/pool.rs b/crates/render/src/raster/pool.rs index 81447263d8..66a575eaee 100644 --- a/crates/render/src/raster/pool.rs +++ b/crates/render/src/raster/pool.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, AssertUnwindSafe, AtomicBool, AtomicRenderStats, InterpreterSettings, JoinHandle, Mutex, Ordering, PageRenderer, Pdf, Pixels, Receiver, + RenderCache, RenderConfig, RenderRequest, RenderStats, RenderedPage, RequestFailureKey, RequestKind, ResultReceiver, Stopwatch, SyncSender, + WorkQueue, catch_unwind, finish, panic_message, parse, render_page, sync_channel, take_warnings, warning_settings, +}; /// How long one page may render before the pool gives up on it (the watchdog). pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20); diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index eba4d9c1c3..79bf05f7db 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,5 +1,8 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; -use super::*; +use super::{ + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, + Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, +}; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be /// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 302184aebc..00b45e266c 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,6 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; -use super::*; +use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. pub(super) struct Flow { diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 6892d3f2ab..d8cb65f32c 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ +use super::TextGlyph; use super::layout::{is_rtl, union}; -use super::*; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 074d4f311198a241461808f691e90a1003dcf58d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 071/120] Refactor sign for strict Clippy checks --- crates/sign/src/pdf/changes.rs | 180 +++++++++++++++++++------------- crates/sign/src/pdf/listing.rs | 2 +- crates/sign/src/pdf/signing.rs | 2 +- crates/sign/src/pdf/types.rs | 2 +- crates/sign/src/pdf/validate.rs | 4 +- 5 files changed, 116 insertions(+), 74 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 2c66418a6c..3a10a74e41 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -1,5 +1,5 @@ use super::types::{Modification, kind}; -use super::*; +use super::{Arc, Dict, Document, HashMap, HashSet, ObjRef, Object}; /// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none: /// an approval signature permits form fill, comments and further signatures). @@ -72,6 +72,18 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if doc.root() != old.root() { disallowed.push(kind::STRUCTURE); } + let classifier = ChangedObjectClassifier { + doc, + old: &old, + old_content: &old_content, + old_metadata, + old_xfa_data: &old_xfa_data, + signed_pages: &signed_pages, + signed_annots: &signed_annots, + signed_resources: &signed_resources, + old_info, + dss: &dss, + }; for num in doc.object_numbers() { if same_place(num) { continue; @@ -93,75 +105,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< // graphics state does not make that a form fill. A container (the resources dictionary, // a category dictionary) that kept every entry it had and only gained names changes // nothing the signed content draws, and is judged as before. - let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v))); - let resource_kind = match (signed_resources.get(&r), &*new) { - (Some(false), _) => Some(kind::PAGE_CONTENT), - (Some(true), Object::Dict(d)) if kept_entries(d) => None, - (Some(true), _) => Some(kind::PAGE_CONTENT), - (None, _) => None, - }; - let what = match (resource_kind, &*new) { - (Some(k), _) => k, - // The Document Security Store's own arrays and dictionaries, when they only grew. - (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it lost, then what it - // gained. Dropping a signed annotation is never hidden behind an added signature. - (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { - let was: HashSet = - before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); - let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); - if removed.is_empty() { - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) - } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { - // A form field or signature widget taken off the page: no permission covers it. - kind::OTHER - } else { - // Deleting comments is a comment change (permitted at P 3 only). - kind::COMMENTS - } - } - (_, Object::Dict(d)) => { - let was = before.as_deref().and_then(Object::as_dict); - if signed_pages.contains(&r) { - // A signed page is always judged by what the page shows, whatever it is now - // relabelled: a rewritten /Contents or /Resources is a page content change. - page_change_kind(doc, &old, d, was) - } else if was.is_some() && Some(r) == old_info { - // Only the signed revision's own /Info dictionary is a metadata change. - kind::METADATA - } else { - change_kind(doc, &old, d, was) - } - } - (_, Object::Stream(s)) => { - // File plumbing is what the stream was when signed: the /Type it has now is - // written by whoever changed it, and the renderer ignores /Type (a page's Form - // XObject relabelled /ObjStm still draws its new content). - let was_plumbing = matches!(before.as_deref(), Some(Object::Stream(b)) if matches!(b.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); - if old_content.contains(&r) { - kind::PAGE_CONTENT - } else if before.is_none() || was_plumbing { - continue; - } else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata { - // Only the signed catalog's XMP stream. A /Type /Metadata label on anything - // else, even one it already had when signed (a page's Form XObject: the - // renderer ignores /Type), does not make rewriting it a metadata change. - kind::METADATA - } else if old_xfa_data.contains(&r) { - // The form's XFA data (`datasets`) or state (`form`) as the signed revision - // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) - // save of a certified form does (#2759). The role comes from the signed - // revision, so a later one can't name page content as XFA data. - kind::FORM_FILL - } else { - kind::OTHER - } - } - _ if before.is_none() => continue, - _ => kind::OTHER, - }; + let Some(what) = classifier.classify(r, &new, before.as_deref()) else { continue }; let ok = match (what, p) { (kind::SIGNATURE | kind::SECURITY_STORE, _) => true, (kind::FORM_FILL, Some(2 | 3) | None) => true, @@ -195,6 +139,102 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +struct ChangedObjectClassifier<'a> { + doc: &'a Document, + old: &'a Document, + old_content: &'a HashSet, + old_metadata: Option, + old_xfa_data: &'a HashSet, + signed_pages: &'a HashSet, + signed_annots: &'a HashSet, + signed_resources: &'a HashMap, + old_info: Option, + dss: &'a HashSet, +} + +impl ChangedObjectClassifier<'_> { + fn classify(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + if let Some(kind) = self.resource_kind(r, new, before) { + return Some(kind); + } + if self.dss.contains(&r.num) { + return Some(kind::SECURITY_STORE); + } + match new { + Object::Array(now) if self.is_signed_annotation_array(r, before) => Some(self.annotation_array_change(now, before)), + Object::Dict(dict) => Some(self.dictionary_change(r, dict, before)), + Object::Stream(stream) => self.stream_change(r, stream, before), + _ if before.is_none() => None, + _ => Some(kind::OTHER), + } + } + + fn resource_kind(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + match (self.signed_resources.get(&r), new) { + (Some(false), _) => Some(kind::PAGE_CONTENT), + (Some(true), Object::Dict(dict)) if self.keeps_previous_entries(dict, before) => None, + (Some(true), _) => Some(kind::PAGE_CONTENT), + (None, _) => None, + } + } + + fn keeps_previous_entries(&self, dict: &Dict, before: Option<&Object>) -> bool { + before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| dict.get(key) == Some(value))) + } + + fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool { + self.signed_annots.contains(&r) && before.is_some_and(|object| object.as_array().is_some()) + } + + fn annotation_array_change(&self, now: &[Object], before: Option<&Object>) -> &'static str { + let was: HashSet = + before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|reference| !kept.contains(reference)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|reference| !was.contains(reference)).collect(); + added_annots(self.doc, &added) + } else if removed.iter().any(|reference| self.old.get(*reference).as_dict().is_some_and(|dict| dict.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } + } + + fn dictionary_change(&self, r: ObjRef, dict: &Dict, before: Option<&Object>) -> &'static str { + let previous = before.and_then(Object::as_dict); + if self.signed_pages.contains(&r) { + // A signed page is judged by what the page shows, whatever its new labels say. + page_change_kind(self.doc, self.old, dict, previous) + } else if previous.is_some() && Some(r) == self.old_info { + // Only the signed revision's own /Info dictionary is a metadata change. + kind::METADATA + } else { + change_kind(self.doc, self.old, dict, previous) + } + } + + fn stream_change(&self, r: ObjRef, stream: &pdfcraft_cos::Stream, before: Option<&Object>) -> Option<&'static str> { + // File plumbing is the stream's role when signed; a later /Type label cannot change it. + let was_plumbing = matches!(before, Some(Object::Stream(previous)) if matches!(previous.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); + if self.old_content.contains(&r) { + Some(kind::PAGE_CONTENT) + } else if before.is_none() || was_plumbing { + None + } else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r) == self.old_metadata { + // Only the signed catalog's XMP stream is metadata. + Some(kind::METADATA) + } else if self.old_xfa_data.contains(&r) { + // Signed XFA data and state packets are changed by a form fill. + Some(kind::FORM_FILL) + } else { + Some(kind::OTHER) + } + } +} + /// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` /// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one /// XFA stream holds its template too, and is not listed. diff --git a/crates/sign/src/pdf/listing.rs b/crates/sign/src/pdf/listing.rs index 88379b1836..cbcf339715 100644 --- a/crates/sign/src/pdf/listing.rs +++ b/crates/sign/src/pdf/listing.rs @@ -1,6 +1,6 @@ use super::types::{Modification, Status, TrustStore}; use super::validate::{is_doc_timestamp, validate_doc_timestamp, validate_into}; -use super::*; +use super::{Arc, Dict, DigestAlg, Document, HashMap, HashSet, ObjRef, Object, SignatureInfo}; /// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of /// one document: incremental edits only append to the file, so the signed bytes and their diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index 0d00c68a61..71dd10b302 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -1,5 +1,5 @@ use super::listing::{mk_rotation, nums, sig_fields}; -use super::*; +use super::{Certificate, Dict, DigestAlg, DigitalId, Document, HashSet, Object, PdfString, SaveOptions, SignError, SignedData, Stream}; // ── signing ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/sign/src/pdf/types.rs b/crates/sign/src/pdf/types.rs index c061bc3699..881f503573 100644 --- a/crates/sign/src/pdf/types.rs +++ b/crates/sign/src/pdf/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Certificate; /// Certificates the user trusts for signing (Acrobat: Trusted Certificates). #[derive(Clone, Debug, Default)] diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 1f0b8c3424..668798c20b 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -1,7 +1,9 @@ use super::changes::classify_changes; use super::listing::{DigestCache, list, nums, sig_fields, text}; use super::types::{Detail, Fill, Modification, Status, TrustStore}; -use super::*; +use super::{ + Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, +}; /// Validate one signature dictionary. pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option { From e7a60f2fd4871284c35a0b5737b0b7e5f7415325 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 072/120] Refactor ui canvas for strict Clippy checks --- crates/ui-egui/src/canvas.rs | 3515 ++++++++++++++--------- crates/ui-egui/src/canvas/area.rs | 2 +- crates/ui-egui/src/canvas/bars.rs | 5 +- crates/ui-egui/src/canvas/cache.rs | 5 +- crates/ui-egui/src/canvas/find.rs | 2 +- crates/ui-egui/src/canvas/navigation.rs | 71 +- crates/ui-egui/src/canvas/organize.rs | 95 +- crates/ui-egui/src/canvas/selection.rs | 2 +- crates/ui-egui/src/canvas/xform.rs | 2 +- crates/ui-egui/src/canvas/zoom.rs | 2 +- 10 files changed, 2202 insertions(+), 1499 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index edcd645693..adf22626b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -808,106 +808,1287 @@ impl DocView { } } -/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. -pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Keyboard scrolling applies to this frame's page view only, never later. - let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { - app.views[index].end_find_following(); +/// The renders a frame wants (page, scale, tag, tile), and the pages visible. +type PageRenders = (Vec<(usize, f32, u64, Option)>, Vec); + +/// What the page-scroll closure reads: this frame's zoom, scrolling, tool and document state. +#[derive(Clone, Copy)] +struct PageEnv<'a> { + added: &'a Vec, + allowed: bool, + author: &'a String, + auto_delta: Vec2, + avail: Rect, + boost_extra: Vec2, + by_line: &'a String, + calc_order: bool, + can_copy: bool, + can_crop: bool, + can_fill: bool, + can_modify: bool, + comments_hidden: bool, + content_h: f32, + content_w: f32, + custom_stamp: &'a Option, + date_text: &'a Result, + doc: &'a pdfcraft_engine::Document, + doc_links: &'a Vec, + edge_delta: Vec2, + editing_content: bool, + form: &'a Arc>, + generation: u64, + glide: Vec2, + hand: bool, + info: &'a DocInfo, + initials: &'a Option, + key_scroll: f32, + middle_gesture: bool, + modal: bool, + navigation_enabled: bool, + paper: Color32, + ppp: f32, + precise_scroll: Vec2, + prefs: &'a comments::CommentPrefs, + preparing: bool, + rects: &'a Vec, + redact_mark: ([f64; 3], f64), + scale: f32, + selection_wheel: bool, + selects_text: bool, + signature: &'a Option, + single_paging: bool, + t: &'a Tokens, + table_preview: &'a Option, + tag: u64, + text_style: &'a pdfcraft_engine::AddedText, + tool: QuickTool, + units: crate::units::Unit, + unobstructed: bool, + visible_pages: &'a Vec, + y_shift: f32, + fill_text_size: f64, + today: (i64, u32, u32), +} + +/// What the page-scroll closure produces for the rest of the frame. +#[derive(Default)] +struct PageOut { + badge_hover: Option<(Pos2, String)>, + canvas_action: Option, + clicked_link: Option, + copy_selected: bool, + field_menu: Option, + field_placed: bool, + field_props: bool, + hover_text: Option<(Pos2, String)>, + image_action: Option, + open_boxes: bool, + open_initials: bool, + open_signature: bool, + open_table: Option<(usize, [f64; 4])>, + refused: Option, + stamp_placed: bool, +} + +/// The page-scroll closure's body: lays out and draws the visible pages, their tools and overlays, and +/// returns the renders wanted and the pages visible. +/// The state of one pass over the visible pages: what is read, and what the pages add up to. +struct PageFrame<'a> { + env: PageEnv<'a>, + resp: &'a egui::Response, + ptr: &'a PagePointer, + origin: Pos2, + visible: Rect, + pressed: bool, + pointer: Option, + signature_preview: &'a mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, + out: PageOut, + wanted: Vec<(usize, f32, u64, Option)>, + visible_now: Vec, + current: usize, + best_overlap: f32, + current_overlap: f32, + current_height: f32, + pressed_page: Option, + calc_hover: Option, +} + +/// Visible heights (points) closer than this are equal: one page size can meet the viewport a +/// fraction of a point differently once the scroll offset is applied. +const PAGE_TIE: f32 = 0.5; + +/// Draws page `i` of the visible pages: its raster, the tools' input and overlays. +/// One visible page: its index, its rectangle on screen and the transform to and from the page. +#[derive(Clone, Copy)] +struct PageSlot { + i: usize, + r: Rect, + xf: PageXform, +} + +/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders +/// it wants. +fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { + let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let info = frame.env.info; + let painter = ui.painter(); + // Shadows would darken the preceding page when pages touch. + if view.show_page_gaps { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); } - // The Search panel closed: its search moves to the find bar. - let search_open = app.right == Some(crate::RightPanel::Search); - if let Some(f) = app.views[index].find.as_mut() - && f.in_panel - && !search_open - { - f.in_panel = false; - } - // The snapshot notice bar's Close was clicked: close here, where no document borrow is - // held. The tab is looked up by id: indices may have shifted since the click, and the - // flag rides its own view, so a stale flag can never close the wrong tab. - let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); - if let Some(i) = pending { - app.views[i].close_requested = false; - if app.has_unsaved_work(i) { - app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + painter.rect_filled(r, CornerRadius::ZERO, paper); + if let Some(err) = view.errors.get(&i) { + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); + icons::paint( + ui, + Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), + "triangle-alert", + 26.0, + Color32::from_rgb(0xC8, 0x3A, 0x3A), + ); + let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); + let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); + painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); + } else { + let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); + // A GPU may take smaller textures than these (OpenGL drivers report as little as + // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); + // Whole-page raster: sharp when small, a low-res backdrop when tiled. + let (want_scale, want_tag) = if tiled { + let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); + (bs, scale_tag(bs)) } else { - app.close_tab(i); + (scale, tag) + }; + // Under tiles, a whole-page raster sharper than the backdrop (from before the page + // grew past the tiling size) is a better backdrop: keep demanding it as it is. + match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { + Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), + None => frame.wanted.push((i, want_scale, want_tag, None)), } - return; - } - let Some(doc) = app.session.get(app.views[index].id) else { return }; - let info = &doc.info; - let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { - let [r, g, b] = p.background; - Color32::from_rgb(r, g, b) - }); - if info.pages.is_empty() { - app.views[index].auto_scroll.cancel(); - ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); - return; - } - // The print dialog queues its own rasters later in the frame. Closing it drops them. - if app.dialog != Some(crate::Dialog::Print) { - app.views[index].print_pages.clear(); - } - // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. - let preparing = app.is_preparing(); - // Prepare a form ▸ Calculation order: the order's steps and links on the page. - let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; - // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = - (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); - let text_style = app.text_style.clone(); - let show_secured_notice = app.show_secured_notice; - let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. The notice says so unless - // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ - // Security, stay as they are. - let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - let repaired = !doc.repair_log().is_empty(); - match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { - #[cfg(not(target_arch = "wasm32"))] - Some(Notice::Reload) => app.watch.reload_request = Some(view.id), - #[cfg(target_arch = "wasm32")] - Some(Notice::Reload) => {} - Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), - Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), - Some(Notice::Unlock) => app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)), - // Flags the view: the tab closes at the top of the next frame, where no document - // borrow is held. - Some(Notice::Close) => { - if let Some(view) = app.views.get_mut(index) { - view.close_requested = true; + match view.pages.get(&i) { + Some(p) => { + if !tiled && p.tag == want_tag { + xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } else { + // A backdrop, or a raster at an older scale until the new one arrives. + xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } + } + None => { + let now = ui.input(|inp| inp.time); + let since = *view.waiting_since.entry(i).or_insert(now); + let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; + painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); } } - Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), - Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, - Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), - // Back on in Preferences ▸ General. - Some(Notice::HideSecured) => app.show_secured_notice = false, - None => {} + if tiled && r.intersects(visible) { + paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side)); + } } - // No dialog, close prompt or palette over the page: only then does page input count. - let modal = app.modal_open(); - let unobstructed = !modal && !app.palette_open; - let organize_zoom = app.organize_zoom; - let view = &mut app.views[index]; - if view.organize { - // One remembered grid size for every document, kept in the settings. - view.set_grid_zoom(organize_zoom); - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); - app.organize_zoom = view.grid_zoom(); - return; +} + +/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, +/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. +fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) { + let PageEnv { ppp, scale, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let painter = ui.painter(); + // Device-pixel geometry of the scaled page, and the visible part of it + // (found by mapping the visible screen corners back into the page). Sides round + // up as whole-page rasters do, so the last partial row and column get tiles. + let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); + // Adaptive tile side for this zoom and page, within the GPU texture limit: + // a zoomed viewport or a giant sheet is covered by a few large tiles + // instead of many small ones, and each tile costs one full page interpret. + // Never zero (u32 division below). + let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); + let vis = r.intersect(visible); + let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); + let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); + let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); + let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); + let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); + // Tiles of earlier zooms first, stretched, the nearest scale last (on top): + // until the current tiles arrive, a zoom shows them rather than the backdrop. + let mut older: Vec<(f32, u32, u32, &TextureHandle)> = + view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); + older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, ox, oy, tex) in older { + let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); + let [w, h] = tex.size(); + let (x, y) = (ox as f32, oy as f32); + xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); + } + // Visible tiles, nearest the viewport centre first: each tile costs a full + // page interpret, so the workers sharpen what is looked at before the edges. + let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); + let mut coords: Vec<(u32, u32)> = Vec::new(); + for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { + for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { + coords.push((tx, ty)); + } + } + coords.sort_by(|a, b| { + let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); + let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); + da.total_cmp(&db) + }); + for (tx, ty) in coords { + let (x, y) = (tx * ts, ty * ts); + let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); + if w == 0 || h == 0 { + continue; + } + frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); + if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { + let (fw, fh) = (dw as f32, dh as f32); + xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( + painter, + tex.id(), + x as f32 / fw, + y as f32 / fh, + (x + w) as f32 / fw, + (y + h) as f32 / fh, + ); + } + } +} + +/// What the page's tools did with the pointer this frame. +struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + consumed: bool, + /// A form field is under the pointer and has set its cursor. + over_field: bool, + /// Redact or Highlight draws boxes off text. + area_tool: bool, +} + +/// Comments: tools, selection, moving and resizing come before text selection. Form fields take +/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then +/// comments; then text selection. +fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + author, + by_line, + can_crop, + can_fill, + can_modify, + comments_hidden, + custom_stamp, + date_text, + doc, + doc_links, + editing_content, + form, + info, + initials, + prefs, + preparing, + redact_mark, + signature, + table_preview, + text_style, + tool, + units, + fill_text_size, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } - let avail = ui.available_rect_before_wrap(); - view.viewport_w = avail.width(); - view.viewport_h = avail.height(); - let old_zoom = view.zoom; - view.fit_zoom(info); + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + if let QuickTool::Stamp(kind) = tool + && allowed + && let Some(p) = ptr.within(xf.rect) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() { + // Centred on the click, upright as the page is shown. + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; + let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); + let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: i, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.clone(), + })); + frame.out.stamp_placed = true; + } + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + ui.ctx().set_cursor_icon(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); + let u = info.pages[i].view_to_user(vx, vy); + let (x, y) = (u[0] as f64, u[1] as f64); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page: i, + rect: [x, y, x, y], + name: cs.name.clone(), + file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, + author: author.clone(), + }); + frame.out.stamp_placed = true; + } + } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + if let QuickTool::Fill(ft) = tool + && allowed + && !comments::fill_grabs(&pcx, view) + { + match crate::fill_sign::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + ft, + fill_text_size, + view, + signature.as_ref(), + initials.as_ref(), + frame.signature_preview, + author, + date_text, + ) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + 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); + } + if tool == QuickTool::Crop && crate::crop::page_input(ui, resp, &xf, ptr, i, info, view, can_crop) { + view.current = i; + frame.out.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 field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let o = crate::prepare::page_input(ui, resp, &xf, ptr, i, info, form, field_tool, allowed, view); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + (o.consumed || field_tool.is_some(), false) + } else { + use crate::forms_ui::FieldPointer; + let field = if 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) + }; + // 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; + let boxing = area_tool && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + 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) + }; + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view); + let on_objects = + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, resp, &xf, ptr, i, doc, view); + let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { + let generation = doc.edit_generation(); + match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + } + } else { + Vec::new() + }; + let over_source = tool == QuickTool::AddText + && pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) + && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) + }); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } + let on_content = !on_objects && editing_content && can_modify && { + source_blocked + || crate::content_ui::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + text_style, + view, + ) + || tool == QuickTool::AddText && !over_source + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + let generation = doc.edit_generation(); + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(ui, resp, &xf, ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); + !others_own_pointer && (text_used || image_used) + }; + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, resp, &xf, ptr, i, info, doc_links, view); + if tool == QuickTool::Select + && resp.secondary_clicked() + && can_modify + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + { + view.links.context = Some((i, l.index)); + } + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + PageInput { consumed, over_field, area_tool } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !input.consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + text_pointer_input( + ui, + view, + &text, + TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p, over_field: input.over_field }, + ); + } + } +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); + } + } + } + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, + over_field: bool, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: bool, + column: bool, +} + +fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { + let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer, over_field } = pointer; + let (vx, vy) = xf.screen_to_view(screen_pointer); + let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); + let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + update_text_cursor(ui, resp, over_field, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(ui: &egui::Ui, resp: &egui::Response, over_field: bool, gesture: &TextGesture<'_>) { + let cursor_free = !gesture.over_link && !over_field && (resp.contains_pointer() || resp.dragged()); + if gesture.column && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if gesture.over_text && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !gesture.over_link { + let now = ui.input(|input| input.time); + let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); + let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } + } + if gesture.resp.drag_started() && press_here && !gesture.over_link { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; + } + } +} + +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } + } + } + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} + +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) + } else { + 0 + }; + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } else if gesture.resp.clicked() && view.clicks < 2 && !gesture.over_link { + let shift = ui.input(|input| input.modifiers.shift); + match ( + view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(ui, painter, &xf, i, info, form, view, pointer); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(name) = crate::calc_order_ui::paint_page(painter, &xf, i, info, form, &view.calc_order, generation, selected, pointer) { + frame.calc_hover = Some(name); + } + } + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // 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 + { + 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("?"))]) + } + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + frame.out.hover_text = Some((p, label)); + if resp.clicked() && tool == QuickTool::Select && !input.consumed { + frame.out.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 frame.out.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) + { + frame.out.hover_text = Some((p, text)); + } + } + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + 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)) { + frame.out.badge_hover = Some((p, 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 frame.out.hover_text.is_none() { + frame.out.hover_text = frame.out.badge_hover.take(); + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { + let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; + frame.out.badge_hover = None; + let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); + if !r.intersects(visible.expand(400.0)) { + return; + } + if pressed && frame.pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { + frame.pressed_page = Some(i); + } + if r.intersects(visible) { + frame.visible_now.push(i); + view.screen_rects.push((i, r)); + view.screen_xforms.push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); + } + let overlap = r.intersect(visible).height(); + if i == view.current { + frame.current_overlap = overlap; + frame.current_height = r.height(); + } + // Pages shown equally (two rows wholly on screen) differ only by rounding: the + // topmost one counts. + if overlap > frame.best_overlap + PAGE_TIE { + frame.best_overlap = overlap; + frame.current = i; + } + let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; + paint_page_surface(ui, view, frame, &PageSlot { i, r, xf }); + let painter = ui.painter(); + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); + if editing_content { + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); + } + if view.show_page_gaps { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + } + + let slot = PageSlot { i, r, xf }; + let input = page_tool_input(ui, view, frame, &slot); + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); + view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); + view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + // A press that lands on something drawn over the page (a menu, a dialog) is not one. + if view.all_text && frame.pressed_page == Some(i) { + view.all_text = false; + } + text_layer(ui, view, frame, &slot, &input); + page_overlays(ui, view, frame, &slot, &input); +} + +fn draw_pages( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + env: &PageEnv<'_>, + out: &mut PageOut, + signature_preview: &mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, +) -> PageRenders { + let PageEnv { + allowed, + auto_delta, + avail, + boost_extra, + calc_order, + can_copy, + can_modify, + content_h, + content_w, + doc, + edge_delta, + form, + generation, + glide, + hand, + info, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + precise_scroll, + prefs, + preparing, + selection_wheel, + single_paging, + unobstructed, + visible_pages, + y_shift, + .. + } = *env; + // egui's drag responses accept every pointer button. Keep a wheel gesture from also + // selecting text, drawing a mark, or panning with the Hand tool. + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + // Only interactions pause; the document must keep its original colours. + ui.set_opacity(opacity); + } + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } + // 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); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + let origin = resp_rect.min - vec2(0.0, y_shift); + let visible = viewport.translate(resp_rect.min.to_vec2()); + view.screen_rects.clear(); + view.screen_xforms.clear(); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); + let pointer = ptr.pos; + // A press on a page makes it the current one (as in Acrobat), so a page command with + // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page + // gone to earlier that still shows whole beside it. + let pressed = unobstructed && ptr.pressed().is_some(); + if resp.secondary_clicked() { + // Set again below when the right-click lands on a link. + view.links.context = None; + } + let mut frame = PageFrame { + env: *env, + resp: &resp, + ptr: &ptr, + origin, + visible, + pressed, + pointer, + signature_preview, + out: std::mem::take(out), + wanted: Vec::new(), + visible_now: Vec::new(), + current: view.current, + best_overlap: -1.0, + current_overlap: -1.0, + current_height: f32::INFINITY, + pressed_page: None, + calc_hover: None, + }; + for &i in visible_pages { + draw_page(ui, view, &mut frame, i); + } + let PageFrame { + wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + } = frame; + let PageOut { + badge_hover, + mut canvas_action, + clicked_link, + mut copy_selected, + mut field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + } = page_out; + let painter = ui.painter(); + 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); + } + // 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 + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view + // so it can reach text off screen (#1670); the selection follows the pointer on its page. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && let Some(p) = ptr.pos + { + let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); + if dy != 0.0 { + view.end_find_following(); + ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); + // Keep scrolling while the pointer rests there and sends no events. + ui.ctx().request_repaint(); + } + } + // The page navigated to stays current while no page shows more of itself, so the next + // page step starts from it rather than from a page further down the screen (#188). + // It also stays current while it is wholly on screen: a short page (a cheque) gone to + // from the Pages panel shows less of itself than the tall page below it, and must not + // hand the highlight to that page. + if current_overlap >= best_overlap - PAGE_TIE || current_overlap >= current_height - PAGE_TIE { + current = view.current; + } + if let Some(p) = pressed_page { + current = p; + } + if view.continuous { + view.current = current; + } + resp.context_menu(|ui| { + // Preparing a form: the selected field's menu. + if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Several fields: Align, Center, Distribute, Set Fields to Same Size. + if !view.prepare.also.is_empty() { + use crate::prepare::Arrange as A; + let others = view.prepare.also.clone(); + let anchor = (name.clone(), wi); + let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { + if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { + view.pending_edit = crate::prepare::arrange(form, &anchor, &others, op); + ui.close(); + } + }; + ui.menu_button(tl!("Align"), |ui| { + pick(ui, A::AlignLeft, "Left"); + pick(ui, A::AlignRight, "Right"); + pick(ui, A::AlignTop, "Top"); + pick(ui, A::AlignBottom, "Bottom"); + pick(ui, A::AlignCenterV, "Center vertically"); + pick(ui, A::AlignCenterH, "Center horizontally"); + }); + ui.menu_button(tl!("Distribute"), |ui| { + pick(ui, A::DistributeH, "Horizontally"); + pick(ui, A::DistributeV, "Vertically"); + }); + ui.menu_button(tl!("Set Fields to Same Size"), |ui| { + pick(ui, A::SameHeight, "Height"); + pick(ui, A::SameWidth, "Width"); + pick(ui, A::SameSize, "Both"); + }); + ui.separator(); + } + if ui.button(tl!("Properties…")).clicked() { + field_menu = Some(FieldMenu::Properties); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { + field_menu = Some(FieldMenu::Duplicate(name.clone())); + ui.close(); + } + ui.separator(); + let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; + if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { + view.pending_edit = crate::prepare::delete_selected(view); + ui.close(); + } + return; + } + if let Some((page, index)) = view.links.context { + if ui.button(tl!("Properties…")).clicked() { + view.links.open_existing = Some((page, index)); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { + view.pending_edit = Some(Edit::DeleteLink { page, index }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { + view.pending_edit = Some(Edit::RemoveLinks { pages: None }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + return; + } + if view.comments.selected.is_none() { + let selected = view.selected_text().is_some_and(|t| !t.is_empty()); + let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { + copy_selected = true; + ui.close(); + } + ui.separator(); + } + // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. + let mut copy_image = None; + if view.comments.selected.is_none() + && let Some((page, at)) = view.comments.context_at + { + // The page's content is parsed once per right-click, not on every frame the menu is open. + let key = egui::Id::unique("copy-image-under-menu"); + let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); + let hit = match cached { + Some(c) => c.2, + None => { + let hit = doc.page_image_at(page, at); + ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); + hit + } + }; + if let Some(image) = hit { + if ui.button(tl!("Copy Image")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImage(page, image)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); + ui.close(); + } + ui.separator(); + } + } + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); + }); + *out = PageOut { + badge_hover, + canvas_action, + clicked_link, + copy_selected, + field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + }; + (wanted, visible_now) +} + +/// A zoom the fit mode changed keeps the page at the top of the view in place. +fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { let prev_viewport = view.viewport_screen; let top_y = prev_viewport.min.y; @@ -922,86 +2103,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); if let Some((page, fy, rel_y)) = anchor { - view.zoom_anchor = Some((page, 0.5, fy, vec2(avail.width() / 2.0, rel_y))); + view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); } else { - view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail.width() / 2.0, MARGIN))); + view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); } } +} - // Pinch / ctrl+scroll zoom, anchored on the current page. - let zoom_delta = crate::motion::zoom_delta(ui.ctx()); - // Exclude the previous frame's scrollbar tracks from gesture activation. - let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); - // Full screen: a click on the document goes forward a page and a shift-click back, as in - // Acrobat, unless the click did something else (a link, a word selected). Decided below, - // once the pages have had the click. - let present_click = - view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; - // Shift as held now, or as the click's own event carried it (a synthetic click). - let present_back = present_click - && ui.input(|i| { - i.modifiers.shift - || i.events.iter().any(|e| { - matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) - }) - }); - if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { - let z = view.zoom * zoom_delta; - match zoom_anchor(ui) { - Some(p) => view.zoom_at(z, p), - None => view.set_zoom(z), - } - } - view.viewport_screen = avail; - let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) - } else { - view.auto_scroll.cancel(); - Vec2::ZERO - }; +/// What the pager needs to know about the page row on screen. +#[derive(Clone, Copy)] +struct PagingCx<'a> { + row: &'a [usize], + rects: &'a [Rect], + avail: Rect, + middle_gesture: bool, + unobstructed: bool, +} - // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view - // that way, so a point beyond what is shown can be reached without letting go of the tool. - let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = drawing_edge_pointer(ui); - crate::autoscroll::edge_scroll(pointer, avail, dt) - } else { - Vec2::ZERO - }; - if edge_delta != Vec2::ZERO { - ui.ctx().request_repaint(); - } - - let content_w = view.content_width(info, avail.width()); - let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); - // When not scrolling continuously, only one row is shown at a time: the current page, or the - // current two-page spread (with the cover rule). These indices drive paging, which pages are - // drawn, and the content height (#752). Empty for a document with no pages. - let shown_row = |view: &DocView| -> Vec { - if rects.is_empty() { - return Vec::new(); - } - let last = rects.len() - 1; - let cur = view.current.min(last); - match view.layout { - PageLayout::Single => vec![cur], - PageLayout::TwoUp if view.cover && cur == 0 => vec![0], - PageLayout::TwoUp => { - let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; - if first < last { vec![first, first + 1] } else { vec![first] } - } - } - }; - let row = shown_row(view); - let middle_gesture = view.auto_scroll.blocks_input(); - // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns - // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on - // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with - // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow - // keys. Runs before `visible_pages` so the frame that turns the page draws it. - let mut single_paging = false; - let mut edge_turned = false; +/// Not scrolling: when the whole page or spread fits, the wheel turns pages instead. Returns whether +/// paging owns the wheel, and whether it turned the page at an edge. +fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (bool, bool) { + let (mut single_paging, mut edge_turned) = (false, false); + let PagingCx { row, rects, avail, middle_gesture, unobstructed } = *cx; if !view.continuous { // The row's bounding box on screen (one page, or a two-page spread). let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); @@ -1030,10 +2153,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } - // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, - // smoothed base and adds only the accelerated surplus. While point suppression is active, - // reconstruct line and page input here so current coarse events survive without exposing an - // older eased tail. + (single_paging, edge_turned) +} + +/// This frame's wheel motion over the page view, after paging and point/line suppression. +struct Wheel { + precise_scroll: Vec2, + line_base: Vec2, + boost_extra: Vec2, + has_page: bool, + /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). + paging_owns_vertical: bool, +} + +/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed +/// base and adds only the accelerated surplus. While point suppression is active, line and page +/// input are reconstructed so current coarse events survive without exposing an older eased tail. +fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { let now = ui.input(|i| i.time); let input = if ui.rect_contains_pointer(avail) { // Match the line distance used by egui's other scroll areas. @@ -1101,1206 +2237,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Page input belongs to egui on the free axis; the pager still owns vertical motion. ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } - // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a - // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or - // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. - // Asked of the preference, not of egui's animation time, which test harnesses zero. - let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; - let over_page = ui.rect_contains_pointer(avail); - ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { - view.fling.stop(); - } - let glide = view.fling.step(now); - if view.fling.gliding() { - ui.ctx().request_repaint(); - } - // Any reader-owned motion ends automatic search following, even when it stays on the same - // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the - // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so - // its programmatic scroll is never mistaken for a reader action. - let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; - if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { - view.end_find_following(); - } - // The wheel may have turned the page above: draw the row it turned to. Measured from the old - // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. - let row = shown_row(view); - let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; - let (y_shift, content_h) = if view.continuous { - (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) - } else { - // Only the current row scrolls into view; the content is its bounding box plus margins. - match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { - Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), - None => (0.0, 0.0), - } - }; + Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } +} - let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { - drag: if middle_gesture { - egui::scroll_area::DragScroll::Never - } else if app.quick_tool == QuickTool::Hand { - egui::scroll_area::DragScroll::Always - } else { - egui::scroll_area::DragScroll::OnTouch - }, - ..Default::default() - }); - // Each document keeps its own scroll position when switching tabs (#189). - scroll = scroll.id_salt(("page-view", view.id)); - // Every scroll here asks for no animation. An animated area would still queue a zero-length - // offset target for the next frame, which overwrites the jump a page turn just set. - scroll = scroll.animated(false); - // A newly opened document starts at the top. eframe saves egui's memory with the settings - // and document ids start again at 1 every launch, so the offset stored under this id can be - // where an earlier session left another document. - if !std::mem::replace(&mut view.shown, true) { - scroll = scroll.scroll_offset(Vec2::ZERO); - } - // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer - // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the - // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a - // click, the page catches up with all the pointer has moved since the press. A jump asked for - // below still wins. - if app.quick_tool == QuickTool::Hand - && !middle_gesture - && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) - { - let moved = if ui.ctx().drag_started_id() == Some(id) { - ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) - } else { - ui.input(|i| i.pointer.delta()) - }; - if moved != Vec2::ZERO { - view.end_find_following(); - } - // Within the content, as egui keeps it: past an end the page would show there for a - // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. - let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); - } - if let Some(v) = view.sync_scroll.take() { - scroll = scroll.scroll_offset(v); - } - if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { - let r = rects[page.min(rects.len() - 1)]; - let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); - } else if let Some((page, frac)) = view.goto.take() { - let page = page.min(rects.len() - 1); - let r = rects[page]; - // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() - // too high for a frame and then hopped into place. (`avail` is the viewport, as the - // scroll bars float.) - let max_y = (content_h - avail.height()).max(0.0); - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); - // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off - // to one side. A page already shown whole, or one filling the window's width, keeps the - // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a - // page that fits is centred, and a wider one scrolled just far enough to fill the window. - // The page counts with its side gutters, which keep its edge clear of the floating - // quick-action bar as the layout does. - let (x, w) = (view.last_scroll.x, avail.width()); - let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; - if (target - x).abs() > 0.5 { - scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); - } - } - let ppp = ui.ctx().pixels_per_point(); - let scale = view.render_scale(ppp); - let tag = scale_tag(scale); - // Hold sharp re-renders while the zoom is still moving: a new scale every frame would - // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD - // plot with ~1000 images renders for seconds) the sharp raster never arrives and the - // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the - // sharp request goes out once the zoom settles. - let now_scale = ui.input(|i| i.time); - if (scale - view.last_scale).abs() > f32::EPSILON { - view.last_scale = scale; - view.sharp_hold_until = now_scale + 0.35; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); - } - let sharp_held = now_scale < view.sharp_hold_until; - view.current_scale_tag = tag; - let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; - // Text selection runs for the Select tool and for the markup tools (highlight…). - let selects_text = match tool { - QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, - QuickTool::Select => !preparing, - QuickTool::Redact | QuickTool::ColumnSelect => true, - QuickTool::Hand - | QuickTool::Measure(_) - | QuickTool::Crop - | QuickTool::EnhanceCamera - | QuickTool::Fill(_) - | QuickTool::Field(_) - | QuickTool::AddText - | QuickTool::EditText - | QuickTool::Stamp(_) - | QuickTool::CustomStamp(_) - | QuickTool::Link - | QuickTool::SignArea { .. } - | QuickTool::MarqueeZoom - | QuickTool::Snapshot => false, - }; - let prefs = &app.comment_prefs; - let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); - let units = app.units; - let allowed = doc.allows_annotation(); - let can_copy = doc.allows_copying(); - let mut copy_selected = false; - let comments_hidden = doc.comments_hidden(); - let form = doc.form.clone(); - let generation = doc.edit_generation(); - let can_fill = doc.allows_form_filling(); - let can_crop = doc.allows_assembly(); - let mut open_boxes = false; - let signature = app.signature.clone(); - let initials = app.initials.clone(); - let mut open_initials = false; - let author = app.comment_prefs.author.clone(); - let today = app.session.today(); - // In Preferences ▸ Date format, or why it can't be stamped into the PDF. - let date_text = crate::date_text_for_pdf(&app.session); - let mut refused: Option = None; - let by_line = app.session.stamp_by_line(&author); - let mut stamp_placed = false; - let mut image_action: Option = None; - let custom_stamp = match app.quick_tool { - QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), - _ => None, - }; - 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; - let mut field_menu: Option = None; - let mut open_props: Option<(usize, usize)> = None; - let mut field_props = false; - let mut field_placed = false; - let mut open_table: Option<(usize, [f64; 4])> = None; - // The table being set up in its window, shown on its own document's page. - let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); - let can_modify = doc.allows_modification(); - if preparing { - crate::prepare::after_refresh(view, &form); - } else { - view.prepare.selected = None; - } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); - view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); - let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; - let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; - if editing_content { - crate::content_ui::after_refresh(view, &added); - } else { - view.content.selected = None; - } - view.move_background.prepare(ui.ctx(), doc, &view.content, scale); +/// What decides which renders a frame asks for. +#[derive(Clone, Copy)] +struct RenderPlan<'a> { + visible_pages: &'a [usize], + tag: u64, + scale: f32, + sharp_held: bool, + pan_held: bool, + hand: bool, +} - // A destination's position (bookmarks, links) refines `goto`: its point at the window's - // top-left, or the centre of a /FitR rectangle at the window's centre. - if let Some((page, fx, fy, rel)) = view.goto_point.take() { - let r = rects[page.min(rects.len() - 1)]; - // Within the content, so an unreachable point never shows a frame scrolled past the end. - let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - if let Some(fx) = fx { - scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); - } - if let Some(fy) = fy { - scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); - } - } - let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { - // egui's drag responses accept every pointer button. Keep a wheel gesture from also - // selecting text, drawing a mark, or panning with the Hand tool. - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - // Only interactions pause; the document must keep its original colours. - ui.set_opacity(opacity); - } - // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y - // moves the content down. All scroll without easing, unlike egui's wheel default. - // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution - // until after the text widgets when a selection owns that drag. - let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); - view.page_widget = Some(resp.id); - // A drag lasts only while egui reports it, so the mark can't outlive one. - if !resp.dragged_by(egui::PointerButton::Primary) { - view.selection_drag = false; - } - // 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); - } else if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - } - let origin = resp_rect.min - vec2(0.0, y_shift); - let painter = ui.painter(); - let visible = viewport.translate(resp_rect.min.to_vec2()); - let mut wanted = Vec::new(); - let mut visible_now = Vec::new(); - view.screen_rects.clear(); - view.screen_xforms.clear(); - // Visible heights (points) closer than this are equal: one page size can meet the - // viewport a fraction of a point differently once the scroll offset is applied. - const TIE: f32 = 0.5; - let mut current = view.current; - let mut best_overlap = -1.0f32; - let mut current_overlap = -1.0f32; - let mut current_height = f32::INFINITY; - // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), - // or a press they hold. The page modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp, modal); - let pointer = ptr.pos; - // A press on a page makes it the current one (as in Acrobat), so a page command with - // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page - // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ptr.pressed().is_some(); - let mut pressed_page = None; - if resp.secondary_clicked() { - // Set again below when the right-click lands on a link. - view.links.context = None; - } - 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; - } - if pressed && pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { - pressed_page = Some(i); - } - if r.intersects(visible) { - visible_now.push(i); - view.screen_rects.push((i, r)); - view.screen_xforms - .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); - } - let overlap = r.intersect(visible).height(); - if i == view.current { - current_overlap = overlap; - current_height = r.height(); - } - // Pages shown equally (two rows wholly on screen) differ only by rounding: the - // topmost one counts. - if overlap > best_overlap + TIE { - best_overlap = overlap; - current = i; - } - let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; - // Shadows would darken the preceding page when pages touch. - if view.show_page_gaps { - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); - } - painter.rect_filled(r, CornerRadius::ZERO, paper); - if let Some(err) = view.errors.get(&i) { - painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); - icons::paint( - ui, - Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), - "triangle-alert", - 26.0, - Color32::from_rgb(0xC8, 0x3A, 0x3A), - ); - let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); - let msg = - ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); - painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); - } else { - let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); - // A GPU may take smaller textures than these (OpenGL drivers report as little as - // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); - // Whole-page raster: sharp when small, a low-res backdrop when tiled. - let (want_scale, want_tag) = if tiled { - let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); - (bs, scale_tag(bs)) - } else { - (scale, tag) - }; - // Under tiles, a whole-page raster sharper than the backdrop (from before the page - // grew past the tiling size) is a better backdrop: keep demanding it as it is. - match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { - Some(p) => wanted.push((i, tag_scale(p.tag), p.tag, None)), - None => wanted.push((i, want_scale, want_tag, None)), - } - match view.pages.get(&i) { - Some(p) => { - if !tiled && p.tag == want_tag { - xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } else { - // A backdrop, or a raster at an older scale until the new one arrives. - xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } - } - None => { - let now = ui.input(|inp| inp.time); - let since = *view.waiting_since.entry(i).or_insert(now); - let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; - painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); - } - } - if tiled && r.intersects(visible) { - // Device-pixel geometry of the scaled page, and the visible part of it - // (found by mapping the visible screen corners back into the page). Sides round - // up as whole-page rasters do, so the last partial row and column get tiles. - let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); - // Adaptive tile side for this zoom and page, within the GPU texture limit: - // a zoomed viewport or a giant sheet is covered by a few large tiles - // instead of many small ones, and each tile costs one full page interpret. - // Never zero (u32 division below). - let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); - let vis = r.intersect(visible); - let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); - let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); - let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); - let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); - let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); - // Tiles of earlier zooms first, stretched, the nearest scale last (on top): - // until the current tiles arrive, a zoom shows them rather than the backdrop. - let mut older: Vec<(f32, u32, u32, &TextureHandle)> = view - .tiles - .iter() - .filter(|((p, t, _, _), _)| *p == i && *t != tag) - .map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)) - .collect(); - older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, ox, oy, tex) in older { - let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); - let [w, h] = tex.size(); - let (x, y) = (ox as f32, oy as f32); - xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); - } - // Visible tiles, nearest the viewport centre first: each tile costs a full - // page interpret, so the workers sharpen what is looked at before the edges. - let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); - let mut coords: Vec<(u32, u32)> = Vec::new(); - for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { - for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { - coords.push((tx, ty)); - } - } - coords.sort_by(|a, b| { - let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); - let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); - da.total_cmp(&db) - }); - for (tx, ty) in coords { - let (x, y) = (tx * ts, ty * ts); - let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); - if w == 0 || h == 0 { - continue; - } - wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); - if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { - let (fw, fh) = (dw as f32, dh as f32); - xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( - painter, - tex.id(), - x as f32 / fw, - y as f32 / fh, - (x + w) as f32 / fw, - (y + h) as f32 / fh, - ); - } - } - } - } - view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); - if editing_content { - view.move_background.paint(painter, &xf, i, info, &view.content, pointer); - } - if view.show_page_gaps { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); - } - - // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.clicked() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(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); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - ft, - app.fill_text_size, - view, - signature.as_ref(), - initials.as_ref(), - &mut app.signature_preview, - &author, - &date_text, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - 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); - } - 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 field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); - field_props |= o.properties; - field_placed |= o.placed; - open_table = open_table.or(o.table); - (o.consumed || field_tool.is_some(), false) - } else { - use crate::forms_ui::FieldPointer; - let field = if 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) - }; - // 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; - let boxing = area_tool && { - let text = view.page_text(i); - let over_text = |p: Pos2| { - 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) - }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); - let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); - let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { - let generation = doc.edit_generation(); - match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - } - } else { - Vec::new() - }; - let over_source = tool == QuickTool::AddText - && pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, &added, p) - && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) - }); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(&added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - let on_content = !on_objects && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - &text_style, - view, - ) - || tool == QuickTool::AddText && !over_source - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - let generation = doc.edit_generation(); - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = - crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); - let image_used = crate::edit_text_ui::image_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &images, - view, - &mut image_action, - !others_own_pointer && (image_owns_pointer || !text_used), - ); - !others_own_pointer && (text_used || image_used) - }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); - - let preview_target = - (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); - view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); - view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); - - // Any press on the page ends Select All of the document: text read or not, any tool, - // on a link too. - // A press that lands on something drawn over the page (a menu, a dialog) is not one. - if view.all_text && pressed_page == Some(i) { - view.all_text = false; - } - // Text layer: find matches, selection, I-beam and drag-to-select. - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(text) = view.texts.get(&i).cloned() { - if let Some(f) = &view.find { - for (k, (mp, range)) in f.matches.iter().enumerate() { - if *mp != i { - continue; - } - let current = f.current == Some(k); - for lr in text.line_rects(range.clone()) { - let fill = if current { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - painter.rect_filled(to_screen(lr).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for lr in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(sel) = view.selection.filter(|s| s.page == i) { - for lr in sel.rects(&text) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - // The rectangle being drawn, as Acrobat shows it while column-selecting. - if let Span::Column { rect } = sel.span - && resp.dragged() - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } - if selects_text - && !consumed - && let Some(p) = pointer.filter(|p| r.contains(*p)) - { - let (vx, vy) = xf.screen_to_view(p); - let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]); - let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p)); - // 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); - } - // The pages' press, kept when its release came in the same frame (#2100): it - // still starts multi-click selection. - let press_here = ptr.origin.is_some_and(|o| r.contains(o)); - let pressed = ptr.pressed().is_some(); - if pressed && press_here && !over_link { - let now = ui.input(|inp| inp.time); - // The same limits egui uses to count clicks on release. - let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let is_consecutive = - now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(p) <= max_dist); - view.clicks = if is_consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(p); - - // Multi-click highlights immediately on pointer press instead of release (#1024). - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if clicks >= 2 && over_text { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } - if resp.drag_started() && press_here && !over_link { - // A drag replaces the selection without passing through none, so a - // re-drag over text whose bar Copy closed would keep the bar hidden. - view.actions_dismissed = None; - view.selection_drag = true; - let origin = ptr.origin.unwrap_or(p); - let (ox, oy) = xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if column { - Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } }) - } else { - text.nearest(ox, oy).map(|a| Selection::run(i, a, a)) - }; - } - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if resp.dragged() - && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) - { - match &mut sel.span { - // After a multi-click the drag extends by its unit (#1685), as in - // Acrobat and browsers: whole words or lines, always keeping the one - // clicked, which holds the anchor whichever way the drag went. - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(h) = text.nearest(vx, vy) - && let Some((first, last)) = click_unit(&text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(h) = text.nearest(vx, vy) { - *head = h; - } - } - Span::Column { rect } => { - rect[2] = vx; - rect[3] = vy; - } - } - } - // A rectangle around no text selects nothing. - if resp.drag_stopped() - && view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text(&text).is_empty()) - { - view.selection = None; - } - // Quick clicks widen the selection: two a word, three the line, four the page. - // Release-based fallback for platforms or synthetic events that report double_clicked. - let release_clicks = if resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - // Only on the frame egui reports the multi-click: `view.clicks` outlives it, and - // acting on every frame re-selected the word as soon as a selection was cleared - // (a highlight made by the double-click left its word selected). - if release_clicks > 0 && clicks >= 2 && over_text { - if view.selection.is_none() { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } else if resp.clicked() && view.clicks < 2 && !over_link { - // ⇧-click extends a selection on this page to the click, keeping its - // anchor (#527). Otherwise (no shift, no selection on this page, or a - // column selection, which has no anchor glyph; a selection cannot yet - // span pages) a click clears the selection. - let shift = ui.input(|inp| inp.modifiers.shift); - match (view.selection.as_mut().filter(|s| shift && s.page == i).map(|s| &mut s.span), text.nearest(vx, vy)) { - (Some(Span::Run { head, .. }), Some(h)) => *head = h, - _ => view.selection = None, - } - } - } - } - - if tool == QuickTool::Select && !consumed { - crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - comments::page_after_text(&resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(&resp, i, info, view); - } - if area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = - crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) - { - calc_hover = Some(name); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); - } - - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - if view.highlight_fields { - for f in form.iter() { - let required = f.has(pdfcraft_engine::field_flags::REQUIRED); - for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { - let r = w.rect; - let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = - if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if w.round { - let radius = sr.width().min(sr.height()) / 2.0; - painter.circle_filled(sr.center(), radius, tint); - painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(sr, CornerRadius::same(1), tint); - painter.rect_stroke(sr, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } - } - // 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 - { - 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("?"))]) - } - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - hover_text = Some((p, 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)); - } - } - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - 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 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); - } - // 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 - // document area, like #759. Combine direct and smoothed motion in one request so mixed - // Point and Line events retain their full contribution. - if selection_wheel { - let smooth = - if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; - let delta = precise_scroll + boost_extra + smooth; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - } - // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view - // so it can reach text off screen (#1670); the selection follows the pointer on its page. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && let Some(p) = ptr.pos - { - let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); - if dy != 0.0 { - view.end_find_following(); - ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); - // Keep scrolling while the pointer rests there and sends no events. - ui.ctx().request_repaint(); - } - } - // The page navigated to stays current while no page shows more of itself, so the next - // page step starts from it rather than from a page further down the screen (#188). - // It also stays current while it is wholly on screen: a short page (a cheque) gone to - // from the Pages panel shows less of itself than the tall page below it, and must not - // hand the highlight to that page. - if current_overlap >= best_overlap - TIE || current_overlap >= current_height - TIE { - current = view.current; - } - if let Some(p) = pressed_page { - current = p; - } - if view.continuous { - view.current = current; - } - resp.context_menu(|ui| { - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { - // Several fields: Align, Center, Distribute, Set Fields to Same Size. - if !view.prepare.also.is_empty() { - use crate::prepare::Arrange as A; - let others = view.prepare.also.clone(); - let anchor = (name.clone(), wi); - let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { - if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { - view.pending_edit = crate::prepare::arrange(&form, &anchor, &others, op); - ui.close(); - } - }; - ui.menu_button(tl!("Align"), |ui| { - pick(ui, A::AlignLeft, "Left"); - pick(ui, A::AlignRight, "Right"); - pick(ui, A::AlignTop, "Top"); - pick(ui, A::AlignBottom, "Bottom"); - pick(ui, A::AlignCenterV, "Center vertically"); - pick(ui, A::AlignCenterH, "Center horizontally"); - }); - ui.menu_button(tl!("Distribute"), |ui| { - pick(ui, A::DistributeH, "Horizontally"); - pick(ui, A::DistributeV, "Vertically"); - }); - ui.menu_button(tl!("Set Fields to Same Size"), |ui| { - pick(ui, A::SameHeight, "Height"); - pick(ui, A::SameWidth, "Width"); - pick(ui, A::SameSize, "Both"); - }); - ui.separator(); - } - if ui.button(tl!("Properties…")).clicked() { - field_menu = Some(FieldMenu::Properties); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { - field_menu = Some(FieldMenu::Duplicate(name.clone())); - ui.close(); - } - ui.separator(); - let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; - if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { - view.pending_edit = crate::prepare::delete_selected(view); - ui.close(); - } - return; - } - if let Some((page, index)) = view.links.context { - if ui.button(tl!("Properties…")).clicked() { - view.links.open_existing = Some((page, index)); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { - view.pending_edit = Some(Edit::DeleteLink { page, index }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - ui.separator(); - if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { - view.pending_edit = Some(Edit::RemoveLinks { pages: None }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - return; - } - if view.comments.selected.is_none() { - let selected = view.selected_text().is_some_and(|t| !t.is_empty()); - let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); - if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { - copy_selected = true; - ui.close(); - } - ui.separator(); - } - // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. - let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { - // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::unique("copy-image-under-menu"); - let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); - let hit = match cached { - Some(c) => c.2, - None => { - let hit = doc.page_image_at(page, at); - ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); - hit - } - }; - if let Some(image) = hit { - if ui.button(tl!("Copy Image")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImage(page, image)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); - ui.close(); - } - ui.separator(); - } - } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); - }); - (wanted, visible_now) - }); - view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { - // At the top once the page's top edge shows where going to a page puts it (a gap - // below the window's top). Within a point, as for wheel paging, so layout rounding - // can't hide an edge. - let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) - }); - - view.viewport_screen = out.inner_rect; - view.auto_scroll.paint(ui, avail); - if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { - copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); - } - - // Collect all main-page demand, including cached surfaces, for byte admission later. - // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding - // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched - // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that - // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; - // pages with nothing on screen yet still queue. - let scroll_offset = out.state.clamped_offset(); - // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas - // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag - // background. The output gives us the area id, and egui's pinned ScrollArea registers those - // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. - // Ask for their exact responses instead of treating any held drag as scrolling: a text - // selection, a panel splitter or another widget's drag must not excuse the programmatic jump - // a search flash makes, while a track click (which moves the offset with no pointer motion) - // must still count as the reader scrolling. - let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); - let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); - if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { - view.end_find_following(); - } - let now_settle = ui.input(|i| i.time); - if (scroll_offset - view.last_scroll).length() > 2.0 { - view.pan_hold_until = now_settle + 0.25; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); - } - if glide != Vec2::ZERO { - view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); - } - view.last_scroll = scroll_offset; - let pan_held = now_settle < view.pan_hold_until; - let (mut wanted, visible_now) = out.inner; +/// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the +/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. +fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { + let (mut wanted, visible_now) = renders; + let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; // Single-page and two-page views draw only the current row, so the next one was first asked // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, // as continuous scrolling does for the pages just off screen. @@ -2419,7 +2374,176 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { })); view.frame_queue = queue; view.frame_visible = visible_now.iter().copied().collect(); +} +/// Acts on the notice bar button that was clicked. +fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { + match notice { + #[cfg(not(target_arch = "wasm32"))] + Some(Notice::Reload) => app.watch.reload_request = app.views.get(index).map(|view| view.id), + #[cfg(target_arch = "wasm32")] + Some(Notice::Reload) => {} + Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), + Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), + Some(Notice::Unlock) => { + if let Some(doc) = app.views.get(index).and_then(|view| app.session.get(view.id)) { + app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)); + } + } + // Flags the view: the tab closes at the top of the next frame, where no document + // borrow is held. + Some(Notice::Close) => { + if let Some(view) = app.views.get_mut(index) { + view.close_requested = true; + } + } + Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), + Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, + Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, + None => {} + } +} + +/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The +/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own +/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). +fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + let Some(i) = pending else { return false }; + app.views[i].close_requested = false; + if app.has_unsaved_work(i) { + app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + } else { + app.close_tab(i); + } + true +} + +/// Where the pages are laid out, for placing the scroll area. +#[derive(Clone, Copy)] +struct ScrollGeometry<'a> { + rects: &'a [Rect], + y_shift: f32, + content_w: f32, + content_h: f32, + avail: Rect, + middle_gesture: bool, + hand: bool, +} + +/// The page view's scroll area, with the offset this frame's jumps, drags and zoom anchor ask for. +fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, middle_gesture, hand } = *geometry; + let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { + drag: if middle_gesture { + egui::scroll_area::DragScroll::Never + } else if hand { + egui::scroll_area::DragScroll::Always + } else { + egui::scroll_area::DragScroll::OnTouch + }, + ..Default::default() + }); + // Each document keeps its own scroll position when switching tabs (#189). + scroll = scroll.id_salt(("page-view", view.id)); + // Every scroll here asks for no animation. An animated area would still queue a zero-length + // offset target for the next frame, which overwrites the jump a page turn just set. + scroll = scroll.animated(false); + // A newly opened document starts at the top. eframe saves egui's memory with the settings + // and document ids start again at 1 every launch, so the offset stored under this id can be + // where an earlier session left another document. + if !std::mem::replace(&mut view.shown, true) { + scroll = scroll.scroll_offset(Vec2::ZERO); + } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + if moved != Vec2::ZERO { + view.end_find_following(); + } + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } + if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { + let r = rects[page.min(rects.len() - 1)]; + let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); + } else if let Some((page, frac)) = view.goto.take() { + let page = page.min(rects.len() - 1); + let r = rects[page]; + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } + } + scroll +} + +/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, +/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether +/// a pan is still settling. +fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } + let now_settle = ui.input(|i| i.time); + if (scroll_offset - view.last_scroll).length() > 2.0 { + view.pan_hold_until = now_settle + 0.25; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); + } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } + view.last_scroll = scroll_offset; + now_settle < view.pan_hold_until +} + +/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the +/// text editor was finished. +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { + let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2430,10 +2554,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); }); } - find_bar(view, info.pages.len(), avail, ui, &t); - page_size_hint(view, info, units, avail, unobstructed, ui, &t); + find_bar(view, info.pages.len(), avail, ui, t); + page_size_hint(view, info, units, avail, unobstructed, ui, t); if tool == QuickTool::EnhanceCamera { - crate::camera_ui::bar(ui.ctx(), view, avail, &t, can_modify); + crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); } if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); @@ -2441,17 +2565,545 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - let text_box = crate::forms_ui::text_box(doc, view, &form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { + let text_box = crate::forms_ui::text_box(doc, view, form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { view.pending_edit = Some(e); } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { view.pending_edit = Some(e); } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, &added); + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); if typed.is_some() { view.pending_edit = typed; } + text_done +} + +/// Full screen: the click turns the page unless it did something else first. It waits out the +/// double-click time: turning on the first click's release sent a double-click's second click, +/// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a +/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { + let now = ui.input(|i| i.time); + if let Some(back) = click + && !link_followed + && view.selection.is_none() + && view.clicks < 2 + { + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + view.present_turn = Some((now + delay, !back)); + } + if let Some((due, forward)) = view.present_turn { + if link_followed || view.selection.is_some() || view.presentation.is_none() { + view.present_turn = None; + } else if now >= due { + view.present_turn = None; + view.step_page(forward); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); + } + } +} + +/// Follows the link that was clicked. A link to another file comes back, to be opened once the +/// document is no longer borrowed. +fn follow_link( + app: &mut PdfCraftApp, + index: usize, + target: Option, +) -> Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> { + let view_id = app.views[index].id; + match target? { + LinkTarget::Page(p, dest) => { + let view = &mut app.views[index]; + if let Some(doc) = app.session.get(view.id) { + view.go_to_dest(p, dest, &doc.info); + } + } + LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), + LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), + LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), + LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), + } + None +} + +/// A finished marquee zoom, a signature rectangle drawn, or an empty signature field clicked. +/// Returns the tool to go on with. +fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool) -> QuickTool { + if let Some(done) = app.views[index].marquee_done.take() { + app.finish_marquee(index, done); + } + // A signature rectangle was drawn, or an empty signature field clicked. + let drawn = app.views[index].sign.drawn.take(); + if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { + tool = QuickTool::Select; + app.quick_tool = tool; + app.start_signing(page, Some(rect), None, certify.then_some(2)); + } + if let Some(field) = app.views[index].sign.field.take() { + let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); + if signed { + app.right = Some(RightPanel::Signatures); + if !app.sig_expanded.contains(&field) { + app.sig_expanded.push(field); + } + } else { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); + } + } + tool +} + +/// When not scrolling continuously, only one row is shown at a time: the current page, or the +/// current two-page spread (with the cover rule). These indices drive paging, which pages are +/// drawn, and the content height (#752). Empty for a document with no pages. +fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { + if rects.is_empty() { + return Vec::new(); + } + let last = rects.len() - 1; + let cur = view.current.min(last); + match view.layout { + PageLayout::Single => vec![cur], + PageLayout::TwoUp if view.cover && cur == 0 => vec![0], + PageLayout::TwoUp => { + let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; + if first < last { vec![first, first + 1] } else { vec![first] } + } + } +} + +/// Full screen: a click on the document goes forward a page and a shift-click back, as in +/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { + let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + // Shift as held now, or as the click's own event carried it (a synthetic click). + clicked.then(|| { + ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| { + matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) + }) + }) + }) +} + +/// Pinch / ctrl+scroll zoom, anchored on the pointer or the current page. +fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rect: Rect, enabled: bool) { + if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && enabled && ui.rect_contains_pointer(navigation_rect) { + let z = view.zoom * zoom_delta; + match zoom_anchor(ui) { + Some(p) => view.zoom_at(z, p), + None => view.set_zoom(z), + } + } +} + +/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the +/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point +/// beyond what is shown can be reached without letting go of the tool. +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { + let auto_delta = if unobstructed { + view.auto_scroll.update(ui, avail, false) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; + + // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view + // that way, so a point beyond what is shown can be reached without letting go of the tool. + let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { + let (pointer, dt) = drawing_edge_pointer(ui); + crate::autoscroll::edge_scroll(pointer, avail, dt) + } else { + Vec2::ZERO + }; + if edge_delta != Vec2::ZERO { + ui.ctx().request_repaint(); + } + (auto_delta, edge_delta) +} + +/// What decides whether touchpad momentum goes on. +#[derive(Clone, Copy)] +struct GlideCx { + now: f64, + may_glide: bool, + key_scroll: f32, + auto_delta: Vec2, + zoom_delta: f32, + edge_delta: Vec2, + wheel_scrolled: bool, +} + +/// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a stop, +/// through the same un-eased path. Never while the pager owns the wheel, a gesture or dialog has +/// the input, or animations are off; any click, key, zoom or other scrolling ends it. Any +/// reader-owned motion ends automatic search following, even when it stays on the same page; the +/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for +/// a reader action. Returns this frame's glide. +fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { + let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = glide_interrupted(ui); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } + glide +} + +/// Text selection runs for the Select tool and for the markup tools (highlight…). +fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { + match tool { + QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, + QuickTool::Select => !preparing, + QuickTool::Redact | QuickTool::ColumnSelect => true, + QuickTool::Hand + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot => false, + } +} + +/// Holds sharp re-renders while the zoom is still moving: a new scale every frame would cancel the +/// in-flight full-page interpret and restart it, so on a heavy page (a CAD plot with ~1000 images +/// renders for seconds) the sharp raster never arrives and the view looks stuck on a soft image. +/// The stale raster stays stretched meanwhile; the sharp request goes out once the zoom settles. +fn hold_sharp_renders(ui: &egui::Ui, view: &mut DocView, scale: f32) -> bool { + let now_scale = ui.input(|i| i.time); + if (scale - view.last_scale).abs() > f32::EPSILON { + view.last_scale = scale; + view.sharp_hold_until = now_scale + 0.35; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); + } + now_scale < view.sharp_hold_until +} + +/// A destination's position (bookmarks, links) refines `goto`: its point at the window's top-left, +/// or the centre of a /FitR rectangle at the window's centre. +fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, .. } = *geometry; + if let Some((page, fx, fy, rel)) = view.goto_point.take() { + let r = rects[page.min(rects.len() - 1)]; + // Within the content, so an unreachable point never shows a frame scrolled past the end. + let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + if let Some(fx) = fx { + scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); + } + if let Some(fy) = fy { + scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); + } + } + scroll +} + +/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a +/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // Keyboard scrolling applies to this frame's page view only, never later. + let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } + // The Search panel closed: its search moves to the find bar. + let search_open = app.right == Some(crate::RightPanel::Search); + if let Some(f) = app.views[index].find.as_mut() + && f.in_panel + && !search_open + { + f.in_panel = false; + } + if close_requested_tab(app, index) { + return; + } + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { + let [r, g, b] = p.background; + Color32::from_rgb(r, g, b) + }); + if info.pages.is_empty() { + app.views[index].auto_scroll.cancel(); + ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); + return; + } + // The print dialog queues its own rasters later in the frame. Closing it drops them. + if app.dialog != Some(crate::Dialog::Print) { + app.views[index].print_pages.clear(); + } + // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. + let preparing = app.is_preparing(); + // Prepare a form ▸ Calculation order: the order's steps and links on the page. + let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; + // Edit a PDF: added text and images can be selected, moved and edited. + let editing_content = + (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); + let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; + let view = &mut app.views[index]; + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + let repaired = !doc.repair_log().is_empty(); + let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + apply_notice(app, index, notice); + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + // No dialog, close prompt or palette over the page: only then does page input count. + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; + let organize_zoom = app.organize_zoom; + let view = &mut app.views[index]; + if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); + organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); + return; + } + + let avail = ui.available_rect_before_wrap(); + view.viewport_w = avail.width(); + view.viewport_h = avail.height(); + let old_zoom = view.zoom; + view.fit_zoom(info); + anchor_zoom_change(view, avail.width(), old_zoom); + + // Pinch / ctrl+scroll zoom, anchored on the current page. + let zoom_delta = crate::motion::zoom_delta(ui.ctx()); + // Exclude the previous frame's scrollbar tracks from gesture activation. + let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; + let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); + pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); + view.viewport_screen = avail; + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + + let content_w = view.content_width(info, avail.width()); + let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); + // When not scrolling continuously, only one row is shown at a time: the current page, or the + // current two-page spread (with the cover rule). These indices drive paging, which pages are + // drawn, and the content height (#752). Empty for a document with no pages. + let row = shown_row(view, &rects); + let middle_gesture = view.auto_scroll.blocks_input(); + // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns + // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on + // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with + // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow + // keys. Runs before `visible_pages` so the frame that turns the page draws it. + let (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. + let now = ui.input(|i| i.time); + let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let glide = step_glide(ui, view, avail, &motion); + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view, &rects); + let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; + let (y_shift, content_h) = if view.continuous { + (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) + } else { + // Only the current row scrolls into view; the content is its bounding box plus margins. + match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { + Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), + None => (0.0, 0.0), + } + }; + + let mut scroll = page_scroll_area( + ui, + view, + &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand: app.quick_tool == QuickTool::Hand }, + ); + let ppp = ui.ctx().pixels_per_point(); + let scale = view.render_scale(ppp); + let tag = scale_tag(scale); + // Hold sharp re-renders while the zoom is still moving: a new scale every frame would + // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD + // plot with ~1000 images renders for seconds) the sharp raster never arrives and the + // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the + // sharp request goes out once the zoom settles. + let sharp_held = hold_sharp_renders(ui, view, scale); + view.current_scale_tag = tag; + let hand = app.quick_tool == QuickTool::Hand; + let tool = app.quick_tool; + // Text selection runs for the Select tool and for the markup tools (highlight…). + let selects_text = tool_selects_text(tool, preparing); + let prefs = &app.comment_prefs; + let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); + let units = app.units; + let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let comments_hidden = doc.comments_hidden(); + let form = doc.form.clone(); + let generation = doc.edit_generation(); + let can_fill = doc.allows_form_filling(); + let can_crop = doc.allows_assembly(); + let signature = app.signature.clone(); + let initials = app.initials.clone(); + let author = app.comment_prefs.author.clone(); + let today = app.session.today(); + // In Preferences ▸ Date format, or why it can't be stamped into the PDF. + let date_text = crate::date_text_for_pdf(&app.session); + let by_line = app.session.stamp_by_line(&author); + let custom_stamp = match app.quick_tool { + QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), + _ => None, + }; + let mut open_props: Option<(usize, usize)> = None; + // The table being set up in its window, shown on its own document's page. + let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); + let can_modify = doc.allows_modification(); + if preparing { + crate::prepare::after_refresh(view, &form); + } else { + view.prepare.selected = None; + } + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); + let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; + let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; + if editing_content { + crate::content_ui::after_refresh(view, &added); + } else { + view.content.selected = None; + } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); + + scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let env = PageEnv { + added: &added, + allowed, + author: &author, + auto_delta, + avail, + boost_extra, + by_line: &by_line, + calc_order, + can_copy, + can_crop, + can_fill, + can_modify, + comments_hidden, + content_h, + content_w, + custom_stamp: &custom_stamp, + date_text: &date_text, + doc, + doc_links: &doc_links, + edge_delta, + editing_content, + form: &form, + generation, + glide, + hand, + info, + initials: &initials, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + paper, + ppp, + precise_scroll, + prefs, + preparing, + rects: &rects, + redact_mark, + scale, + selection_wheel, + selects_text, + signature: &signature, + single_paging, + t: &t, + table_preview: &table_preview, + tag, + text_style: &text_style, + tool, + units, + unobstructed, + visible_pages: &visible_pages, + y_shift, + fill_text_size: app.fill_text_size, + today, + }; + let mut page_out = PageOut::default(); + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { + draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) + }); + let PageOut { + badge_hover: _, + canvas_action, + clicked_link, + mut copy_selected, + field_menu, + field_placed, + mut field_props, + hover_text, + mut image_action, + open_boxes, + open_initials, + open_signature, + open_table, + mut refused, + stamp_placed, + } = page_out; + view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { + // At the top once the page's top edge shows where going to a page puts it (a gap + // below the window's top). Within a point, as for wheel paging, so layout rounding + // can't hide an edge. + let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) + }); + + view.viewport_screen = out.inner_rect; + view.auto_scroll.paint(ui, avail); + if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { + copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); + } + + // Collect all main-page demand, including cached surfaces, for byte admission later. + // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding + // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched + // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that + // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; + // pages with nothing on screen yet still queue. + let pan_held = track_scroll(ui, view, &out, glide); + plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); + + let text_done = frame_overlays(ui, view, &env, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } @@ -2498,60 +3150,71 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - // Full screen: the click turns the page unless it did something else first. It waits out the - // double-click time: turning on the first click's release sent a double-click's second click, - // meant to select a word, to the next page (review on #2070). A selection cancels it, and so - // do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns - // once. - let now = ui.input(|i| i.time); - if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 { - let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); - view.present_turn = Some((now + delay, !present_back)); - } - if let Some((due, forward)) = view.present_turn { - if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() { - view.present_turn = None; - } else if now >= due { - view.present_turn = None; - view.step_page(forward); - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); - } - } - match clicked_link { - Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info), - Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), - Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb), - Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)), - Some(LinkTarget::Other(s)) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), - None => {} - } + schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } - if let Some(done) = app.views[index].marquee_done.take() { - app.finish_marquee(index, done); - } - // A signature rectangle was drawn, or an empty signature field clicked. - let drawn = app.views[index].sign.drawn.take(); - if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { - tool = QuickTool::Select; - app.quick_tool = tool; - app.start_signing(page, Some(rect), None, certify.then_some(2)); - } - if let Some(field) = app.views[index].sign.field.take() { - let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); - if signed { - app.right = Some(RightPanel::Signatures); - if !app.sig_expanded.contains(&field) { - app.sig_expanded.push(field); - } - } else { - let page = app.views[index].current; - app.start_signing(page, None, Some(field), None); - } - } + let tool = finish_sign_requests(app, index, tool); app.quick_tool = tool; + let requests = FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + }; + apply_frame_requests(app, index, ui, requests); +} + +/// What the frame's input asked the app to do, once the document is no longer borrowed. +struct FrameRequests { + open_props: Option<(usize, usize)>, + field_props: Option<(String, usize)>, + field_menu: Option, + image_action: Option, + refused: Option, + open_signature: bool, + open_initials: bool, + open_boxes: bool, + open_table: Option<(usize, [f64; 4])>, + form_notice: Option, + copy_selected: bool, + remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, + form: Arc>, + generation: u64, + avail: Rect, +} + +/// Opens the dialogs and runs the app commands the frame asked for. +fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { + let FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + } = requests; if let Some((page, at)) = app.views[index].comments.attach_at.take() { app.attach_file_comment(page, at); } diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index d7997a79a0..ea3b741701 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LayerOp, PdfCraftApp, Pos2, Rect}; /// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. pub(crate) struct SignatureBadge { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 4d916a1b8c..b48027ae35 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme, + vec2, +}; /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index d8a1d1d445..a0ad8fb335 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,5 +1,8 @@ use super::find::Follow; -use super::*; +use super::{ + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, + device_pixels, +}; pub(super) const THUMB_TAG: u64 = 1 << 63; diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index d44ad9d5e2..bcb4b42a20 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 1cf3be91ed..46468a112d 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -1,5 +1,5 @@ use super::zoom::GRID_ZOOM_STEP; -use super::*; +use super::{DestView, DocInfo, DocView, Fit, PT, PageLayout, SIDE, ViewAction, pos2, vec2}; impl DocView { /// Where a bookmark made now should go: the page and the `/XYZ` position `[left, top]` in user @@ -299,6 +299,43 @@ impl DocView { } } +/// ⌘+ / ⌘− / ⌘0 / ⌘1 / ⌘2: zoom the page, or size the pages in the page grid. +fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + // In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed). + let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0))); + if view.organize { + if zoom_in { + view.grid_zoom_request = Some(view.grid_zoom * GRID_ZOOM_STEP); + } + if zoom_out { + view.grid_zoom_request = Some(view.grid_zoom / GRID_ZOOM_STEP); + } + if zoom_reset { + view.grid_zoom_request = Some(1.0); + } + } else { + if zoom_in { + view.zoom_step(true); + } + if zoom_out { + view.zoom_step(false); + } + if zoom_reset { + view.fit = Fit::Page; + view.goto = Some((view.current, 0.0)); + } + } + if pressed(cmd(Key::Num1)) { + view.set_zoom(1.0); + } + if pressed(cmd(Key::Num2)) { + view.fit = Fit::Width; + view.goto = Some((view.current, 0.0)); + } +} + pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { use egui::{Key, KeyboardShortcut, Modifiers}; if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { @@ -331,37 +368,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { view.rotate_view(false); } - // In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed). - let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0))); - if view.organize { - if zoom_in { - view.grid_zoom_request = Some(view.grid_zoom * GRID_ZOOM_STEP); - } - if zoom_out { - view.grid_zoom_request = Some(view.grid_zoom / GRID_ZOOM_STEP); - } - if zoom_reset { - view.grid_zoom_request = Some(1.0); - } - } else { - if zoom_in { - view.zoom_step(true); - } - if zoom_out { - view.zoom_step(false); - } - if zoom_reset { - view.fit = Fit::Page; - view.goto = Some((view.current, 0.0)); - } - } - if pressed(cmd(Key::Num1)) { - view.set_zoom(1.0); - } - if pressed(cmd(Key::Num2)) { - view.fit = Fit::Width; - view.goto = Some((view.current, 0.0)); - } + zoom_shortcuts(view, &pressed); if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 3eae2cc379..7f333b3e46 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -1,6 +1,9 @@ use super::cache::{GRID_TAG, thumbnail_density, thumbnail_rows, thumbnail_scale}; use super::zoom::{GRID_ZOOM_RANGE, GRID_ZOOM_STEP}; -use super::*; +use super::{ + Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction, + device_pixels, icons, pos2, theme, vec2, widgets, +}; /// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes /// the size on every frame, and a slightly soft or slightly large image is fine meanwhile. @@ -210,6 +213,60 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin resp.on_hover_text(label).clicked() } +/// Middle-button auto-scroll: its delta scrolls the grid, and the page controls under it are +/// disabled (but not dimmed) while the gesture lasts. +fn apply_auto_scroll(ui: &mut egui::Ui, middle_gesture: bool, auto_delta: Vec2) { + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + } + if auto_delta != Vec2::ZERO { + ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); + } +} + +/// Drag pages to move them (the selection, or the page grabbed); `drop` is set when the drag ends. +fn drag_pages(view: &mut DocView, resp: &egui::Response, page: usize, drop: &mut bool) { + if resp.drag_started() { + if !view.selected.contains(&page) { + view.selected = [page].into(); + view.select_anchor = Some(page); + } + let mut pages: Vec = view.selected.iter().copied().collect(); + pages.sort_unstable(); + view.org_drag = Some(pages); + } + if resp.drag_stopped() { + *drop = true; + } +} + +/// A page drawn larger than its thumbnail gets a render at the size drawn, while it is in view; +/// until that arrives the thumbnail stands in. +fn request_sharper( + view: &DocView, + page: usize, + want: f32, + page_width: f32, + drawn_width: Option, + in_view: &mut Vec<(usize, f32)>, + sharper: &mut Vec, +) { + in_view.push((page, want)); + if !drawn_width.is_some_and(|w| sharp_enough(w, want)) && !view.errors.contains_key(&page) { + sharper.push(RenderRequest { + page, + kind: RequestKind::Pixels, + tile: None, + scale: want / page_width.max(1.0), + tag: GRID_TAG, + // Grid cells are previews: tiny images are skipped for them. + preview: true, + }); + } +} + pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. @@ -234,14 +291,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - ui.set_opacity(opacity); - } - if auto_delta != Vec2::ZERO { - ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); - } + apply_auto_scroll(ui, middle_gesture, auto_delta); ui.add_space(20.0); let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize; let rows = info.pages.len().div_ceil(cols); @@ -267,19 +317,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); } - // Drag pages to move them (the selection, or the page grabbed). - if resp.drag_started() { - if !view.selected.contains(&i) { - view.selected = [i].into(); - view.select_anchor = Some(i); - } - let mut pages: Vec = view.selected.iter().copied().collect(); - pages.sort_unstable(); - view.org_drag = Some(pages); - } - if resp.drag_stopped() { - drop = true; - } + drag_pages(view, &resp, i, &mut drop); let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); @@ -300,18 +338,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start()); let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok); if !thumb_ok && ui.is_rect_visible(c) { - in_view.push((i, want)); - if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) { - sharper.push(RenderRequest { - page: i, - kind: RequestKind::Pixels, - tile: None, - scale: want / p.width.max(1.0), - tag: GRID_TAG, - // Grid cells are previews: tiny images are skipped for them. - preview: true, - }); - } + request_sharper(view, i, want, p.width, sharp.map(|(w, _)| *w), &mut in_view, &mut sharper); } if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) { ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index 43d5990c57..cfc5967bcf 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocInfo, DocView, Edit, HashMap, PageText, Pos2, Range, Rect, comments, pos2}; /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 24ff25f7d1..91a3739d62 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, DocInfo, DocView, Pos2, Rect, pos2, vec2}; impl DocView { /// Where `page` is drawn on screen this frame (`None` when it is not on screen). diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index b6a9669fd0..91bc4feac8 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, DocView, Fit, GAP, MARGIN, PT, PageLayout, Pos2, Rect, SIDE, SavedDisplay, Vec2, device_pixels, pos2, vec2}; /// The page grid's zoom range and the step of its buttons and keys. pub const GRID_ZOOM_RANGE: std::ops::RangeInclusive = 0.5..=3.0; From f796c71abe2634a7ac0cc3157c9797a76c1659ef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 073/120] Refactor ui commands for strict Clippy checks --- crates/ui-egui/src/commands.rs | 678 ++++++++++++++++++--------------- 1 file changed, 364 insertions(+), 314 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 26fad6f77c..acccbcc55a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -112,56 +112,64 @@ impl PdfCraftApp { } } + /// Why the command can't run right now, for the user. + fn disabled_reason(&self, spec: &CommandSpec) -> String { + match spec.needs { + commands::Needs::Undo => tl!("Nothing to undo").to_string(), + commands::Needs::Redo => tl!("Nothing to redo").to_string(), + commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), + commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), + commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), + commands::Needs::HasRedactions if self.active.is_some() => { + tl!("There are no redaction marks (mark text, areas or pages first)").to_string() + } + commands::Needs::Marks(k) if self.active.is_some() => { + let kind = match k { + pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), + pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), + pdfcraft_engine::MarkKind::Background => tl!("background"), + }; + crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) + } + // Restricted documents ask for their permissions password instead, so only an + // unprotected or a read-only document gets here. + commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { + match self.active_ids().and_then(|(_, id)| self.session.get(id)) { + Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), + _ => tl!("This document isn't password-protected").to_string(), + } + } + commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), + commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { + tl!("The document's security settings don't allow this change").to_string() + } + commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), + commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), + commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), + _ => tl!("Open a document first").to_string(), + } + } + fn execute_unguarded(&mut self, id: &str) -> bool { let Some(spec) = commands::command(id) else { return false }; if !self.command_enabled(spec) { - let why = match spec.needs { - commands::Needs::Undo => tl!("Nothing to undo").to_string(), - commands::Needs::Redo => tl!("Nothing to redo").to_string(), - commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), - commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), - commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), - commands::Needs::HasRedactions if self.active.is_some() => { - tl!("There are no redaction marks (mark text, areas or pages first)").to_string() - } - commands::Needs::Marks(k) if self.active.is_some() => { - let kind = match k { - pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), - pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), - pdfcraft_engine::MarkKind::Background => tl!("background"), - }; - crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) - } - // Restricted documents ask for their permissions password instead, so only an - // unprotected or a read-only document gets here. - commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { - match self.active_ids().and_then(|(_, id)| self.session.get(id)) { - Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), - _ => tl!("This document isn't password-protected").to_string(), - } - } - commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), - commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { - tl!("The document's security settings don't allow this change").to_string() - } - commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), - commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), - commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), - _ => tl!("Open a document first").to_string(), - }; + let why = self.disabled_reason(spec); self.notify(why); return false; } let active = self.active; let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); + self.run_file_command(id, active) + .or_else(|| self.run_view_command(id, active)) + .or_else(|| self.run_page_command(id, active, targets)) + .or_else(|| self.run_edit_command(id, active)) + .or_else(|| self.run_form_command(id, active)) + .or_else(|| self.run_secure_command(id, active)) + .unwrap_or(false) + } + + fn run_file_command(&mut self, id: &str, active: Option) -> Option { match id { - "ui.dock.reset" => { - if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { - self.notify(error); - } - } - "view.side_panel" => self.toggle_side_panel(), - "view.tools_pane" => self.left_open = !self.left_open, "file.open" => self.open_dialog(), "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. @@ -171,7 +179,6 @@ impl PdfCraftApp { "file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"), "file.clear_recent" => self.recent.clear(), "file.pin_folder" => self.pin_folder_dialog(), - "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); } @@ -198,35 +205,58 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } - "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); } } - "page.number" => { - if let Some(i) = active { - let v = &self.views[i]; - let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; - let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); - self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; - } - self.dialog = Some(Dialog::NumberPages); - } link if pdfcraft_engine::links::for_command(link).is_some() => { if let Some(l) = pdfcraft_engine::links::for_command(link) { self.open_url(l.url); } } - "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), - "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), - "edit.undo" => self.undo(), - "edit.redo" => self.redo(), - "edit.find" => { - if let Some(i) = active { - self.views[i].open_find(); + "app.preferences" => self.dialog = Some(Dialog::Preferences), + "protect.remove_hidden" => self.open_remove_hidden(), + "print.dialog" => self.open_print(), + "optimize.advanced" => self.dialog = Some(Dialog::Optimize), + "doc.compare" => self.dialog = Some(Dialog::CompareFiles), + "standards.pdfa" => { + self.pdfa.issues = None; + self.dialog = Some(Dialog::PdfA); + } + "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { + self.wizard.editing = None; + match id { + "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), + "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), + _ => {} + } + self.dialog = Some(Dialog::ActionWizard); + } + "create.blank" => self.create_blank(), + "create.file" => self.open_dialog(), + "create.multiple" => self.create_multiple_dialog(), + "create.multiple_separate" => self.create_multiple_separate_dialog(), + "create.images" => self.create_from_images_dialog(), + "create.clipboard" => self.create_from_clipboard(), + "optimize.reduce" => self.reduce_file_size(), + "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), + "help.about" => self.dialog = Some(Dialog::About), + "help.check_updates" => self.check_for_updates(), + _ => return None, + } + Some(true) + } + + fn run_view_command(&mut self, id: &str, active: Option) -> Option { + match id { + "ui.dock.reset" => { + if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { + self.notify(error); } } + "view.side_panel" => self.toggle_side_panel(), + "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) { // The page box in the toolbar (chrome.rs); select its label so typing replaces it. @@ -287,7 +317,7 @@ impl PdfCraftApp { "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - return v.view_history(id == "view.next_view"); + return Some(v.view_history(id == "view.next_view")); } } "view.next_tab" => self.step_tab(true), @@ -300,54 +330,33 @@ impl PdfCraftApp { "view.theme.system" => self.set_theme_preference(ThemePreference::System), "view.theme.light" => self.set_theme_preference(ThemePreference::Light), "view.theme.dark" => self.set_theme_preference(ThemePreference::Dark), - "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), - tool if crate::comments::CommentTool::from_command(tool).is_some() => { - let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false }; - self.comment_prefs.group_tool[tool.group()] = tool; - self.quick_tool = crate::QuickTool::Comment(tool); - // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. - if self.right.is_none() && !self.comments_panel_closed { - self.choose_right_panel(Some(RightPanel::Comments)); - } - // A text selection made before picking a markup tool is marked right away. - if let (Some(kind), Some(i)) = (tool.markup(), active) - && let Some(doc) = self.session.get(self.views[i].id) + "view.fit_visible" => { + if let Some(i) = self.active + && let Err(e) = self.fit_visible(i) { - let info = &doc.info; - if let Some((page, quads)) = self.views[i].selection_quads(info) { - self.views[i].clear_selection(); - let style = self.comment_prefs.style(tool); - let author = self.comment_prefs.author.clone(); - let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; - self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); - } + self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); } } - "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), - "form.calculation_order" => { - self.left = crate::LeftPanel::CalculationOrder; - // The page edits the fields, so Preview ends; the page grid closes, as for a tool. - self.form_preview = false; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; - } - self.reveal_tools(); - } - "comment.flatten" => { - self.apply_edit(Edit::Flatten { comments: true, fields: false }); - } - "form.flatten" => { - // Flatten what's typed in a field too (#166); a refused value flattens nothing. - if self.commit_form_typing() { - self.apply_edit(Edit::Flatten { comments: false, fields: true }); - } - } - "form.clear" => { + "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, + _ => return None, + } + Some(true) + } + + fn run_page_command(&mut self, id: &str, active: Option, targets: Vec) -> Option { + match id { + "page.combine" => self.open_combine_tab(), + "page.number" => { if let Some(i) = active { - self.views[i].forms.focus = None; + let v = &self.views[i]; + let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; + let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); + self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; } - self.apply_edit(Edit::ResetForm { names: None }); + self.dialog = Some(Dialog::NumberPages); } + "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), + "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { if v.organize { @@ -376,6 +385,80 @@ impl PdfCraftApp { } "page.insert" => self.insert_from_file_dialog(), "page.replace" => self.replace_pages_dialog(), + "page.copy" => self.copy_pages(false), + "page.cut" => self.copy_pages(true), + "page.paste" => self.paste_pages(), + "page.duplicate" => { + self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); + } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } + "page.crop" => { + self.quick_tool = crate::QuickTool::Crop; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + } + "page.boxes" => { + self.boxes_draft.seeded = None; + self.dialog = Some(Dialog::PageBoxes); + } + "page.extract" => self.open_extract_dialog(), + "page.rotate_dialog" => { + if let Some(i) = active { + let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); + self.rotate_draft.to = n; + self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; + } + self.dialog = Some(Dialog::RotatePages); + } + "page.split" => self.dialog = Some(Dialog::Split), + _ => return None, + } + Some(true) + } + + fn run_edit_command(&mut self, id: &str, active: Option) -> Option { + match id { + "edit.attach" => self.attach_file_dialog(), + "edit.undo" => self.undo(), + "edit.redo" => self.redo(), + "edit.find" => { + if let Some(i) = active { + self.views[i].open_find(); + } + } + "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), + tool if crate::comments::CommentTool::from_command(tool).is_some() => { + let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return Some(false) }; + self.comment_prefs.group_tool[tool.group()] = tool; + self.quick_tool = crate::QuickTool::Comment(tool); + // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. + if self.right.is_none() && !self.comments_panel_closed { + self.choose_right_panel(Some(RightPanel::Comments)); + } + // A text selection made before picking a markup tool is marked right away. + if let (Some(kind), Some(i)) = (tool.markup(), active) + && let Some(doc) = self.session.get(self.views[i].id) + { + let info = &doc.info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let style = self.comment_prefs.style(tool); + let author = self.comment_prefs.author.clone(); + let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; + self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); + } + } + } + "comment.flatten" => { + self.apply_edit(Edit::Flatten { comments: true, fields: false }); + } "edit.header_footer" | "edit.watermark" | "edit.background" @@ -416,15 +499,7 @@ impl PdfCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), - "a11y.check" => self.start_accessibility_check(), - "a11y.find_untagged" => self.find_untagged_content(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), - "tools.js_console" => self.dialog = Some(Dialog::JsConsole), - "tools.document_js" => { - self.doc_js = Default::default(); - self.dialog = Some(Dialog::DocumentJs); - } - "app.preferences" => self.dialog = Some(Dialog::Preferences), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; @@ -440,16 +515,6 @@ impl PdfCraftApp { self.choose_right_panel(Some(crate::RightPanel::Search)); } } - "a11y.report" => self.show_accessibility_report(), - "a11y.autotag" => { - if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { - self.choose_right_panel(Some(RightPanel::Tags)); - } - } - "a11y.alt_text" => self.start_alt_text(), - "a11y.autotag_fields" => self.autotag_form_fields(), - "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), - "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), "edit.text" => { self.quick_tool = crate::QuickTool::AddText; @@ -463,6 +528,183 @@ 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.stamp" => { + self.left = crate::LeftPanel::Tool("stamp"); + self.reveal_tools(); + } + "comment.export" => self.export_data_dialog(true, false), + "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), + "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, + "edit.snapshot" => { + self.quick_tool = crate::QuickTool::Snapshot; + self.notify_tr("Drag a rectangle around the area to copy"); + } + "edit.copy" => { + if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { + return Some(self.copy_selected_text(i, &ctx)); + } + return Some(false); + } + "comment.hide_all" => { + if let Some(i) = active { + let id = self.views[i].id; + let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); + if self.session.set_hide_comments(id, hide) { + self.views[i].invalidate_content(); + } + } + } + "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), + "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), + "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), + "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), + "ocr.enhance_camera" => { + // The corners start on the document's edges when they can be found, else + // inset on the current page (camera_ui::page_input). + self.quick_tool = crate::QuickTool::EnhanceCamera; + if let Some(i) = self.active + && let Some(view) = self.views.get_mut(i) + && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) + { + view.camera_done = None; + view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { + page: view.current, + corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), + grab: None, + }); + } + self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); + } + _ => return None, + } + Some(true) + } + + fn run_form_command(&mut self, id: &str, active: Option) -> Option { + match id { + "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), + "form.calculation_order" => { + self.left = crate::LeftPanel::CalculationOrder; + // The page edits the fields, so Preview ends; the page grid closes, as for a tool. + self.form_preview = false; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.reveal_tools(); + } + "form.flatten" => { + // Flatten what's typed in a field too (#166); a refused value flattens nothing. + if self.commit_form_typing() { + self.apply_edit(Edit::Flatten { comments: false, fields: true }); + } + } + "form.clear" => { + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.apply_edit(Edit::ResetForm { names: None }); + } + "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { + let order = match id { + "form.tab_order.row" => pdfcraft_engine::TabOrder::Row, + "form.tab_order.column" => pdfcraft_engine::TabOrder::Column, + _ => pdfcraft_engine::TabOrder::Structure, + }; + let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); + self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); + } + "sign.digital" | "sign.certify" => { + let certify = id == "sign.certify"; + self.quick_tool = crate::QuickTool::SignArea { certify }; + self.notify_tr("Drag to draw the area where the signature should appear."); + } + "sign.certify_invisible" => { + let page = active.map_or(0, |i| self.views[i].current); + self.start_signing(page, None, None, Some(2)); + } + "sign.validate" => { + let certs = self.session.trusted_certificates().to_vec(); + self.apply_trusted_certificates(certs); + self.choose_right_panel(Some(RightPanel::Signatures)); + } + "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), + "form.export_data" => self.export_data_dialog(false, true), + "form.merge_data" => self.merge_data_dialog(), + "form.prepare" => { + self.left = crate::LeftPanel::Tool("form"); + self.reveal_tools(); + self.choose_right_panel(Some(RightPanel::Fields)); + // Like Acrobat, a document without fields gets them detected on the way in. + if let Some((_, id)) = self.active_ids() + && self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable()) + { + self.detect_fields(); + } + } + "form.detect" => self.detect_fields(), + "form.field.properties" => { + if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { + self.open_field_props(&name, w); + } + } + field if crate::prepare::FieldTool::from_command(field).is_some() => { + let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return Some(false) }; + self.quick_tool = crate::QuickTool::Field(tool); + self.left = crate::LeftPanel::Tool("form"); + self.reveal_tools(); + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.notify_fmt( + "Click on the page to add a {tool}, or drag to set its size", + &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))], + ); + } + "sign.fill.signature.remove" => self.signature = None, + "sign.fill.initials.remove" => self.initials = None, + "sign.fill.signature.change" | "sign.fill.initials.change" => { + let initials = id == "sign.fill.initials.change"; + let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() }; + self.signature_draft = saved.map_or_else( + || crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author), + |s| crate::fill_sign::SigDraft::from_saved(initials, s), + ); + self.dialog = Some(Dialog::Signature); + } + fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { + let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; + self.quick_tool = crate::QuickTool::Fill(tool); + let initials = tool == crate::fill_sign::FillTool::Initials; + if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { + self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author); + self.dialog = Some(Dialog::Signature); + } + } + _ => return None, + } + Some(true) + } + + fn run_secure_command(&mut self, id: &str, active: Option) -> Option { + match id { + "a11y.check" => self.start_accessibility_check(), + "a11y.find_untagged" => self.find_untagged_content(), + "tools.js_console" => self.dialog = Some(Dialog::JsConsole), + "tools.document_js" => { + self.doc_js = Default::default(); + self.dialog = Some(Dialog::DocumentJs); + } + "a11y.report" => self.show_accessibility_report(), + "a11y.autotag" => { + if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { + self.choose_right_panel(Some(RightPanel::Tags)); + } + } + "a11y.alt_text" => self.start_alt_text(), + "a11y.autotag_fields" => self.autotag_form_fields(), + "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), + "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "redact.mark" => { self.quick_tool = crate::QuickTool::Redact; self.left = crate::LeftPanel::Tool("redact"); @@ -502,205 +744,13 @@ impl PdfCraftApp { self.dialog = Some(Dialog::RedactApply); } } - "protect.remove_hidden" => self.open_remove_hidden(), - "print.dialog" => self.open_print(), "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), "redact.clear" => { self.apply_edit(Edit::ClearRedactions); } - "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { - let order = match id { - "form.tab_order.row" => pdfcraft_engine::TabOrder::Row, - "form.tab_order.column" => pdfcraft_engine::TabOrder::Column, - _ => pdfcraft_engine::TabOrder::Structure, - }; - let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); - self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); - } - "comment.import" | "form.import_data" => self.import_data_dialog(), - "comment.stamp" => { - self.left = crate::LeftPanel::Tool("stamp"); - self.reveal_tools(); - } - "comment.export" => self.export_data_dialog(true, false), - "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), - "sign.digital" | "sign.certify" => { - let certify = id == "sign.certify"; - self.quick_tool = crate::QuickTool::SignArea { certify }; - self.notify_tr("Drag to draw the area where the signature should appear."); - } - "sign.certify_invisible" => { - let page = active.map_or(0, |i| self.views[i].current); - self.start_signing(page, None, None, Some(2)); - } - "sign.validate" => { - let certs = self.session.trusted_certificates().to_vec(); - self.apply_trusted_certificates(certs); - self.choose_right_panel(Some(RightPanel::Signatures)); - } - "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), - "optimize.advanced" => self.dialog = Some(Dialog::Optimize), - "view.fit_visible" => { - if let Some(i) = self.active - && let Err(e) = self.fit_visible(i) - { - self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); - } - } - "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, - "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, - "edit.snapshot" => { - self.quick_tool = crate::QuickTool::Snapshot; - self.notify_tr("Drag a rectangle around the area to copy"); - } - "edit.copy" => { - if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { - return self.copy_selected_text(i, &ctx); - } - return false; - } - "page.copy" => self.copy_pages(false), - "page.cut" => self.copy_pages(true), - "page.paste" => self.paste_pages(), - "comment.hide_all" => { - if let Some(i) = active { - let id = self.views[i].id; - let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); - if self.session.set_hide_comments(id, hide) { - self.views[i].invalidate_content(); - } - } - } - "form.export_data" => self.export_data_dialog(false, true), - "form.merge_data" => self.merge_data_dialog(), - "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), - "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), - "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), - "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), - "form.prepare" => { - self.left = crate::LeftPanel::Tool("form"); - self.reveal_tools(); - self.choose_right_panel(Some(RightPanel::Fields)); - // Like Acrobat, a document without fields gets them detected on the way in. - if let Some((_, id)) = self.active_ids() - && self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable()) - { - self.detect_fields(); - } - } - "form.detect" => self.detect_fields(), - "doc.compare" => self.dialog = Some(Dialog::CompareFiles), - "standards.pdfa" => { - self.pdfa.issues = None; - self.dialog = Some(Dialog::PdfA); - } - "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { - self.wizard.editing = None; - match id { - "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), - "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), - _ => {} - } - self.dialog = Some(Dialog::ActionWizard); - } - "form.field.properties" => { - if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { - self.open_field_props(&name, w); - } - } - field if crate::prepare::FieldTool::from_command(field).is_some() => { - let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return false }; - self.quick_tool = crate::QuickTool::Field(tool); - self.left = crate::LeftPanel::Tool("form"); - self.reveal_tools(); - if let Some(i) = active { - self.views[i].forms.focus = None; - } - self.notify_fmt( - "Click on the page to add a {tool}, or drag to set its size", - &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))], - ); - } - "sign.fill.signature.remove" => self.signature = None, - "sign.fill.initials.remove" => self.initials = None, - "sign.fill.signature.change" | "sign.fill.initials.change" => { - let initials = id == "sign.fill.initials.change"; - let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() }; - self.signature_draft = saved.map_or_else( - || crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author), - |s| crate::fill_sign::SigDraft::from_saved(initials, s), - ); - self.dialog = Some(Dialog::Signature); - } - fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { - let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return false }; - self.quick_tool = crate::QuickTool::Fill(tool); - let initials = tool == crate::fill_sign::FillTool::Initials; - if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { - self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author); - self.dialog = Some(Dialog::Signature); - } - } - "create.blank" => self.create_blank(), - "create.file" => self.open_dialog(), - "create.multiple" => self.create_multiple_dialog(), - "create.multiple_separate" => self.create_multiple_separate_dialog(), - "create.images" => self.create_from_images_dialog(), - "create.clipboard" => self.create_from_clipboard(), - "optimize.reduce" => self.reduce_file_size(), - "page.duplicate" => { - self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); - } - // Two or more selected pages reverse among themselves; otherwise the whole document. - "page.reverse" => { - let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); - let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; - self.apply_edit(Edit::ReversePages { pages }); - } - "ocr.enhance_camera" => { - // The corners start on the document's edges when they can be found, else - // inset on the current page (camera_ui::page_input). - self.quick_tool = crate::QuickTool::EnhanceCamera; - if let Some(i) = self.active - && let Some(view) = self.views.get_mut(i) - && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) - { - view.camera_done = None; - view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { - page: view.current, - corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), - grab: None, - }); - } - self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); - } - "page.crop" => { - self.quick_tool = crate::QuickTool::Crop; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; - } - self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); - } - "page.boxes" => { - self.boxes_draft.seeded = None; - self.dialog = Some(Dialog::PageBoxes); - } - "page.extract" => self.open_extract_dialog(), - "page.rotate_dialog" => { - if let Some(i) = active { - let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); - self.rotate_draft.to = n; - self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; - } - self.dialog = Some(Dialog::RotatePages); - } - "page.split" => self.dialog = Some(Dialog::Split), - "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), - "help.about" => self.dialog = Some(Dialog::About), - "help.check_updates" => self.check_for_updates(), - _ => return false, + _ => return None, } - true + Some(true) } /// Shows the next tab (or the previous one) in the strip's order, wrapping around at either From bf89bf918c0c5589f6018e828ae511c7b7909586 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 074/120] Refactor ui dialogs for strict Clippy checks --- crates/ui-egui/src/dialogs.rs | 2921 +++++++++++++------------ crates/ui-egui/src/dialogs/prompts.rs | 2 +- 2 files changed, 1559 insertions(+), 1364 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a0c1ca880e..a2cbe902f2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,48 @@ use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, sav const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// What the dialog's widgets asked for this frame; acted on after the modal is drawn. +#[derive(Default)] +struct Pending { + apply: bool, + split_now: Option, + split_ready: Option, + extract_now: bool, + rotate_now: bool, + link_now: Option, + recover: Option, + number_now: Option, + apply_number: bool, + link_command: Option<&'static str>, + protect_now: bool, + boxes_now: bool, + marks_now: bool, + export_now: bool, + props_now: bool, + field_props_now: bool, + bulk_field_props_now: bool, + redact_now: Option, + import_words: bool, + print_go: bool, + revert_now: bool, + overwrite_now: bool, + overwrite_elsewhere: bool, + summarize_now: bool, + optimize_now: bool, + duplicate_now: Option, + replace_now: bool, + open_revision: Option, + a11y_now: bool, + ocr_now: bool, + compare_now: bool, + images_now: bool, + stamp_now: bool, + alt_now: bool, + close: bool, + /// The dialog to switch to, when a widget asked for another one. + next: Option, +} + pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // The page sets hover cursors from the raw pointer position (form fields, links, comments) // before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from @@ -77,43 +119,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.view_draft = Some((id, doc.initial_view())); app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); } - let mut apply = false; - let mut split_now: Option = None; - let mut split_ready: Option = None; - let mut extract_now = false; - let mut rotate_now = false; - let mut link_now: Option = None; - let mut recover: Option = None; - let mut number_now: Option = None; - let mut apply_number = false; - let mut link_command: Option<&'static str> = None; - let mut protect_now = false; - let mut boxes_now = false; - let mut marks_now = false; - let mut export_now = false; - let mut props_now = false; - let mut field_props_now = false; - let mut bulk_field_props_now = false; - let mut redact_now: Option = None; - let mut import_words = false; - let mut print_go = false; - let mut revert_now = false; - let mut overwrite_now = false; - let mut overwrite_elsewhere = false; - let mut summarize_now = false; - let mut optimize_now = false; - let mut duplicate_now: Option = None; - let mut replace_now = false; - let mut open_revision: Option = None; - let mut a11y_now = false; - let mut ocr_now = false; - let mut compare_now = false; - let mut images_now = false; - let mut stamp_now = false; - let mut alt_now = false; - let t = Tokens::get(ctx); - let mut close = false; - let mut next = dialog; + let mut req = Pending::default(); let modal = widgets::show_modal(ctx, "dialog", |ui| { ui.set_width(match dialog { Dialog::Properties(_) => 640.0, @@ -122,1302 +128,1463 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::About => 780.0, _ => 520.0, }); - match dialog { - Dialog::WordCount => { - close = crate::word_count_ui::body(ui, app); - } - Dialog::CreateImages => { - let (go, cancel) = crate::create_ui::image_import_body(ui, app); - images_now = go; - close = go || cancel; - return; - } - Dialog::Properties(tab) => { - ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for (tb, label) in [ - (PropsTab::Description, tl!("Description")), - (PropsTab::InitialView, tl!("Initial View")), - (PropsTab::Security, tl!("Security")), - (PropsTab::Fonts, tl!("Fonts")), - (PropsTab::Custom, tl!("Custom")), - (PropsTab::Advanced, tl!("Advanced")), - (PropsTab::SpaceAudit, tl!("Space Audit")), - ] { - if widgets::mode_tab(ui, label, tab == tb).clicked() { - next = Dialog::Properties(tb); - } - } - }); - ui.separator(); - let units = app.units; - let Some((_, id)) = app.active_ids() else { return }; - let Some(doc) = app.session.get(id) else { return }; - let i = &doc.info; - // Description shows plain values among text fields; they start where the fields' text does. - let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); - let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; - let row = |ui: &mut egui::Ui, k: &str, v: String| { - // Row labels are UI chrome; values are document data and stay as they are. - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.horizontal(|ui| { - ui.add_space(inset); - ui.add( - egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(), - ); - }); - ui.end_row(); - }; - // A file name or path is read left to right even in an RTL UI: the extension - // stays at its visual end, as in the tab strip. - let path_row = |ui: &mut egui::Ui, k: &str, v: String| { - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.scope(|ui| { - ui.set_max_width(420.0); - // Align path text with editable fields while keeping its LTR text order. - ui.add_space(inset); - if v.is_empty() { - ui.label(egui::RichText::new("—").color(t.text_faint)); - } else { - crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); - } - }); - ui.end_row(); - }; - egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { - if tab == PropsTab::SpaceAudit { - ui.add_space(4.0); - crate::optimize_ui::audit_table(ui, &app.space_audit, &t); - return; - } - egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { - PropsTab::Description => { - path_row(ui, "File", doc.name.clone()); - match app.props_draft.as_mut() { - Some((_, draft)) if editable_info => { - for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { - let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - // Subject and Keywords are often a sentence or a list: they wrap and grow - // instead of hiding most of the value past the field's edge. - let field = - if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; - ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); - ui.end_row(); - } - } - _ => { - row(ui, "Title", i.title.clone().unwrap_or_default()); - row(ui, "Author", i.author.clone().unwrap_or_default()); - row(ui, "Subject", i.subject.clone().unwrap_or_default()); - row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); - } - } - row(ui, "Application", i.creator.clone().unwrap_or_default()); - row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); - } - PropsTab::InitialView => { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let editable = doc.allows_modification(); - let pages = i.pages.len(); - let Some((_, v)) = app.view_draft.as_mut() else { return }; - ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); - ui.end_row(); - let label = ui.label(tl!("Navigation tab")); - ui.add_enabled_ui(editable, |ui| { - craft_ui::ComboBox::from_id_salt("iv-nav") - .selected_text(tl!(format!("{:?}", v.navigation) - .replace("PageOnly", "Page Only") - .replace("Pages", "Pages Panel and Page") - .as_str())) - .show_ui(ui, |ui| { - for (n, l) in [ - (N::PageOnly, tl!("Page Only")), - (N::Bookmarks, tl!("Bookmarks Panel and Page")), - (N::Pages, tl!("Pages Panel and Page")), - (N::Attachments, tl!("Attachments Panel and Page")), - (N::Layers, tl!("Layers Panel and Page")), - ] { - ui.selectable_value(&mut v.navigation, n, l); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Page layout")); - ui.add_enabled_ui(editable, |ui| { - let names = [ - (L::Default, tl!("Default")), - (L::SinglePage, tl!("Single Page")), - (L::SinglePageContinuous, tl!("Single Page Continuous")), - (L::TwoUp, tl!("Two-Up (Facing)")), - (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), - (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), - (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), - ]; - let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - craft_ui::ComboBox::from_id_salt("iv-layout") - .selected_text(shown) - .show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Magnification")); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - let names = [ - (M::Default, tl!("Default")), - (M::ActualSize, tl!("Actual Size")), - (M::FitPage, tl!("Fit Page")), - (M::FitWidth, tl!("Fit Width")), - (M::FitHeight, tl!("Fit Height")), - (M::FitVisible, tl!("Fit Visible")), - ]; - let shown = match v.magnification { - M::Percent(p) => format!("{p:.0}%"), - m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), - }; - craft_ui::ComboBox::from_id_salt("iv-mag") - .selected_text(shown) - .show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }) - .response - .labelled_by(label.id); - }); - }); - ui.end_row(); - let label = ui.label(tl!("Open to page")); - ui.add_enabled_ui(editable, |ui| { - let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { - v.page = p - 1; - } - ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); - ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); - ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); - ui.horizontal(|ui| { - ui.label(tl!("Show:")); - widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); - widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); - }); - }); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); - ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); - ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); - }); - }); - ui.end_row(); - } - PropsTab::Security => match doc.security_summary() { - None => { - row(ui, "Security method", tl!("No security").to_string()); - row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); - if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { - link_command = Some("protect.password"); - } - } - Some(sec) => { - row(ui, "Security method", tl!("Password security").to_string()); - row(ui, "Encryption", sec.method.clone()); - row( - ui, - "Opened with", - if sec.pending { - tl!("— (protection is applied when you save)").to_string() - } else if sec.owner { - tl!("Owner password (no restrictions apply)").to_string() - } else { - tl!("User password").to_string() - }, - ); - // A restricted document asks for its permissions password first. - if doc.allows_security_change() || doc.can_unlock() { - ui.horizontal(|ui| { - if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { - link_command = Some("protect.unlock"); - } - if ui.button(tl!("Change settings…")).clicked() { - link_command = Some("protect.password"); - } - if ui.button(tl!("Remove security")).clicked() { - link_command = Some("protect.remove"); - } - }); - } - let p = sec.permissions; - let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); - row( - ui, - "Printing", - if !p.print() { - tl!("Not allowed").to_string() - } else if p.print_high_quality() { - tl!("High resolution").to_string() - } else { - tl!("Low resolution").to_string() - }, - ); - row(ui, "Changing the document", yes(p.modify())); - row(ui, "Document assembly", yes(p.assemble())); - row(ui, "Content copying", yes(p.copy())); - row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); - row(ui, "Commenting", yes(p.annotate())); - row(ui, "Filling of form fields", yes(p.fill_forms())); - } - }, - PropsTab::Fonts => { - if i.fonts.is_empty() { - row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); - } - for f in &i.fonts { - let mut detail = f.kind.clone(); - if let Some(e) = &f.encoding { - detail.push_str(&format!(" · {e}")); - } - detail.push_str(if f.subset { - " · Embedded subset" - } else if f.embedded { - " · Embedded" - } else { - " · Not embedded (substituted)" - }); - row(ui, &f.name, detail); - } - } - PropsTab::Custom => { - // Add takes a new name, Update changes the value of the listed property the - // Name field names, Delete removes it; OK writes the changes as one step. - let editable = doc.allows_modification(); - let Some((_, d)) = app.custom_draft.as_mut() else { return }; - let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"), - ) - .labelled_by(l.id); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"), - ) - .labelled_by(l.id); - ui.end_row(); - // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. - let name = d.name.clone(); - let value = d.value.trim().to_owned(); - let listed = d.props.iter().position(|(k, _)| *k == name); - // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. - let file_props = doc.custom_info(); - let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); - let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); - ui.label(""); - // A file can hold a custom name that `check_custom_key` refuses, such as one - // with a space: the tab lists it, and Add, Update and Delete stay off for it. - let why = |b: egui::Response| match &refused { - Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), - None => b, - }; - // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. - let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); - // `custom_info` lists at most 256 properties, and a new name goes after the file's - // entries, so Add is off once the draft or the file's own list holds 256. - let room = d.props.len() < 256 && file_props.len() < 256; - ui.horizontal(|ui| { - let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); - if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { - d.props.push((name.clone(), value.clone())); - d.name.clear(); - d.value.clear(); - } - let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); - if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() - && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) - { - p.1 = value.clone(); - } - if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() - && let Some(n) = listed.filter(|n| *n < d.props.len()) - { - d.props.remove(n); - d.name.clear(); - d.value.clear(); - } - }); - ui.end_row(); - if d.props.is_empty() { - ui.label(""); - ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); - ui.end_row(); - } - // A click on a property fills the fields with it; values are document data. - let mut picked = None; - for (k, v) in &d.props { - if ui.selectable_label(*k == name, k.as_str()).clicked() { - picked = Some((k.clone(), v.clone())); - } - ui.scope(|ui| { - ui.set_max_width(420.0); - ui.add(egui::Label::new(v.as_str()).wrap()); - }); - ui.end_row(); - } - if let Some((k, v)) = picked { - d.name = k; - d.value = v; - } - } - PropsTab::Advanced => { - row(ui, "PDF version", i.pdf_version.clone()); - path_row(ui, "Location", doc.path.clone().unwrap_or_default()); - row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); - let p = &i.pages[0]; - row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); - row(ui, "Number of pages", i.pages.len().to_string()); - row(ui, "Tagged PDF", yes(i.tagged)); - row(ui, "Form fields", i.fields.len().to_string()); - row(ui, "Comments", i.annotations.len().to_string()); - row(ui, "Layers", i.layers.len().to_string()); - row(ui, "Attachments", i.attachments.len().to_string()); - row(ui, "JavaScript", yes(i.has_javascript)); - // Each incremental update is a revision; earlier ones open as their own document. - let ends = doc.revision_ends(); - if ends.len() > 1 { - ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - // Reserve the full wrapped height before the grid starts the next row. - ui.vertical(|ui| { - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); - } - } - }); - }); - ui.end_row(); - } - // Reading Options: binding and language. - if let Some((_, v)) = app.view_draft.as_mut() { - let editable = doc.allows_modification(); - ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); - widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); - }); - }); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); - let mut lang = v.language.clone().unwrap_or_default(); - if ui - .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) - .labelled_by(l.id) - .changed() - { - v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); - } - ui.end_row(); - } - // What was repaired while reading a damaged file (fidelity: never silent). - let repairs = doc.repair_log(); - row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); - if !repairs.is_empty() { - ui.label(""); - egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { - for r in &repairs { - ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); - } - }); - ui.end_row(); - } - } - // Laid out above, outside the two-column grid. - PropsTab::SpaceAudit => {} - }); - }); - } - Dialog::Split => { - ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((vi, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); - let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); - let marks = app.session.bookmark_splits(id); - let draft = &mut app.split_draft; - use crate::SplitMode as M; - if selected.is_empty() && draft.mode == M::Selection { - draft.mode = M::Pages; - } - widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); - ui.add_enabled_ui(draft.mode == M::Pages, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("Pages per file")); - ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); - }); - }); - widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); - ui.add_enabled_ui(draft.mode == M::Size, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("At most")); - ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); - }); - }); - ui.add_enabled_ui(!marks.is_empty(), |ui| { - widgets::radio_value( - ui, - &mut draft.mode, - M::Bookmarks, - crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), - ) - }); - ui.add_enabled_ui(!selected.is_empty(), |ui| { - widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) - }); - let plan = match draft.mode { - M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), - M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), - M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), - M::Bookmarks => crate::SplitPlan::Bookmarks, - }; - let files = match &plan { - crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), - crate::SplitPlan::Bookmarks => { - Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) - } - crate::SplitPlan::Size(_) => None, - }; - ui.add_space(8.0); - let text = match files { - Some(f) => { - if f == 1 { - crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) - } else { - crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) - } - } - None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), - }; - ui.label(egui::RichText::new(text).color(t.text_muted)); - if files.is_none_or(|f| f > 1) { - split_ready = Some(plan); - } - } - Dialog::ReplacePages => { - let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let Some(d) = app.replace_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - d.to = d.to.clamp(1, count); - d.from = d.from.clamp(1, d.to); - ui.horizontal(|ui| { - let from_label = ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); - let to_label = ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); - if from.changed() || to.changed() { - d.range_edited = true; - } - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); - }); - let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; - let max_start = d.src_pages.saturating_sub(n) + 1; - d.src_from = d.src_from.clamp(1, max_start.max(1)); - ui.horizontal(|ui| { - let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); - ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); - }); - let fits = n <= d.src_pages; - ui.add_space(6.0); - ui.label( - egui::RichText::new(if fits { - tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") - } else { - tl!("The replacement file doesn't have that many pages.") - }) - .small() - .color(t.text_faint), - ); - ui.add_space(10.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { - replace_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::RedactPages => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::RedactSearch => { - let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); - if go { - redact_now = Some(dialog); - } - import_words = import; - close = cancel; - return; - } - Dialog::RedactProps => { - let mut prefs = app.redact_prefs.clone(); - let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); - app.redact_prefs = prefs; - if ok && app.redact_prefs.remember { - app.redact_defaults = app.redact_prefs.clone(); - } - if cancel && let Some(before) = app.redact_before.take() { - app.redact_prefs = before; - } - if ok { - app.redact_before = None; - } - close = ok || cancel; - return; - } - Dialog::RedactApply => { - let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); - let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::LinkProps => { - let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.link_draft.as_mut() else { - close = true; - return; - }; - let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); - if ok { - link_now = Some(crate::link_ui::edit_for(d)); - } - close = ok || cancel; - return; - } - Dialog::Extract => { - let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); - ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - // Page ranges to take instead of the selection (#3593); the hint is the selection. - let hint = match crate::combine_ui::range_of(&selected, total) { - all if all.is_empty() && total > 1 => format!("1-{total}"), - all if all.is_empty() => "1".to_string(), - some => some, - }; - ui.horizontal(|ui| { - let l = ui.label(tl!("Pages")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); - }); - let chosen = app.extract_draft.pages_to_extract(selected, total); - match &chosen { - Ok(pages) if pages.len() == 1 => { - ui.label(tl!("1 page selected.")); - } - Ok(pages) => { - ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); - } - Err(e) => { - ui.colored_label(ui.visuals().error_fg_color, e); - } - } - ui.add_space(4.0); - // The name the pages are saved under (#737); separate files add " (page N)". - ui.horizontal(|ui| { - let l = ui.label(tl!("File name")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); - }); - ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); - ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { - extract_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::RotatePages => { - use pdfcraft_engine::{PageOrientation as O, PageParity as P}; - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let d = &mut app.rotate_draft; - ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Direction:")); - craft_ui::ComboBox::from_id_salt("rotate-dir") - .selected_text(match d.degrees { - 270 => tl!("Counterclockwise 90 degrees"), - 180 => tl!("180 degrees"), - _ => tl!("Clockwise 90 degrees"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); - }); - ui.end_row(); - ui.label(tl!("Pages:")); - ui.vertical(|ui| { - widgets::radio_value(ui, &mut d.which, 0, tl!("All")); - widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.which, 2, tl!("From")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - }); - ui.end_row(); - ui.label(tl!("Rotate:")); - craft_ui::ComboBox::from_id_salt("rotate-parity") - .selected_text(match d.parity { - P::Both => tl!("Even and Odd Pages"), - P::Even => tl!("Even Pages Only"), - P::Odd => tl!("Odd Pages Only"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); - ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); - ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); - }); - ui.end_row(); - ui.label(""); - craft_ui::ComboBox::from_id_salt("rotate-orient") - .selected_text(match d.orientation { - O::Both => tl!("Landscape and Portrait Pages"), - O::Landscape => tl!("Landscape Pages"), - O::Portrait => tl!("Portrait Pages"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); - ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); - ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); - }); - ui.end_row(); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - rotate_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::DuplicateField => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.duplicate_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); - widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.all, false, tl!("From")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; - duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - // Never reached: `dialogs` shows this movable window above and returns before the modal. - // The arm keeps the match exhaustive. - Dialog::FieldTable => close = true, - Dialog::Optimize => { - let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); - optimize_now = ok; - close = ok || cancel; - if std::mem::take(&mut app.optimize_draft.audit) { - app.refresh_space_audit(); - next = Dialog::AuditSpace; - } - return; - } - Dialog::CertificateViewer => { - ui.set_width(700.0); - let trusted = app.session.trusted_certificates().to_vec(); - let Some(v) = app.cert_viewer.as_mut() else { - close = true; - return; - }; - let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); - close = done; - match action { - Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), - Some(crate::sign_ui::CertAction::Export(c)) => { - app.export_certificate(&c); - } - None => {} - } - return; - } - Dialog::AuditSpace => { - if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { - next = Dialog::Optimize; - } - return; - } - Dialog::Sign => { - if crate::sign_ui::dialog(ui, app, &t) { - app.sign_draft = None; - close = true; - } - return; - } - Dialog::SummarizeComments => { - ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(tl!("Choose a layout:")); - let _ = ui.add(widgets::radio(true, tl!("Comments only"))); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(tl!("Sort comments by:")); - craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { - for s in pdfcraft_engine::SummarySort::ALL { - ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); - } - }); - }); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { - summarize_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::Revert => { - let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Revert"), true).clicked() { - revert_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::OverwriteChanged => { - #[cfg(not(target_arch = "wasm32"))] - let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); - #[cfg(target_arch = "wasm32")] - let name = String::new(); - ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { - overwrite_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { - overwrite_elsewhere = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::Print => { - let Some((i, id)) = app.active_ids() else { - close = true; - return; - }; - if app.print_draft.handling == crate::print_ui::Handling::Label { - app.read_label_crops(id); - } - let Some(doc) = app.session.get(id) else { return }; - let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); - let rasters = crate::print_ui::preview_rasters( - &app.print_draft, - &sizes, - &labels, - ui.ctx().pixels_per_point(), - ui.ctx().input(|i| i.max_texture_side) as f32, - ); - let view = &mut app.views[i]; - view.set_print_grayscale(app.print_draft.grayscale); - view.queue_print_previews(&rasters); - let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { - view.need_thumbnail(p, true); - view.page_preview(p) - }); - print_go = go; - close = go || cancel; - return; - } - Dialog::RemoveHidden => { - let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::Sanitize => { - let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::FieldProps => { - let Some(d) = app.field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::prepare::body(ui, d, &t); - field_props_now = apply; - close = apply || cancel; - return; - } - Dialog::BulkFieldProps => { - let Some(d) = app.bulk_field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::bulk_fields::body(ui, d, &t); - bulk_field_props_now = apply; - close = cancel; - return; - } - Dialog::CommentProps => { - let (apply, cancel) = crate::comment_props::body(ui, app, &t); - props_now = apply; - close = apply || cancel; - return; - } - Dialog::Signature => { - let (apply, cancel, browse) = - crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); - if browse { - app.pick_signature_image(); - } - if apply { - let d = std::mem::take(&mut app.signature_draft); - let tool = if d.initials { - app.initials = d.saved(); - crate::fill_sign::FillTool::Initials - } else { - app.signature = d.saved(); - crate::fill_sign::FillTool::Signature - }; - app.quick_tool = crate::QuickTool::Fill(tool); - app.toast = None; - } - close = apply || cancel; - return; - } - Dialog::AltText => { - let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); - alt_now = save; - close = save || cancel; - return; - } - Dialog::CreateStamp => { - let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); - stamp_now = save; - close = save || cancel; - return; - } - Dialog::PdfA => { - close = crate::standards_ui::body(ui, app, &t); - return; - } - Dialog::ActionWizard => { - ui.set_width(620.0); - close = crate::actions_ui::body(ui, app, &t); - return; - } - Dialog::CompareFiles => { - let (go, cancel) = crate::compare_ui::body(ui, app, &t); - compare_now = go; - close = go || cancel; - return; - } - Dialog::JsConsole => { - ui.set_width(640.0); - close = crate::js_ui::console_body(ui, app, &t); - return; - } - Dialog::DocumentJs => { - ui.set_width(640.0); - close = crate::js_ui::document_js_body(ui, app, &t); - return; - } - Dialog::Preferences => { - // The settings outgrow a short window: scroll them rather than run off the screen. - // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); - // A scroll area starts from the room the dialog offers it, and a modal offers more - // each frame for the first few, so with OK outside the scroll the dialog would grow - // over those frames. Asking for the full height as the least it may scroll in - // (which only applies when the settings overflow it) opens the dialog at full size. - // A scrollbar of its own: the floating one lies over the right edge of the theme - // cards and other full-width rows (#3557). - ui.scope(|ui| { - ui.spacing_mut().scroll.floating = false; - egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); - }); - close = crate::js_ui::preferences_buttons(ui); - return; - } - Dialog::RecognizeText => { - let (go, cancel) = crate::ocr_ui::body(ui, app, &t); - ocr_now = go; - close = go || cancel; - return; - } - Dialog::AccessibilityOptions => { - let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); - a11y_now = start; - close = start || cancel; - return; - } - Dialog::Export(kind) => { - let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind); - export_now = apply; - close = apply || cancel; - return; - } - Dialog::Marks(kind) => { - ui.set_width(720.0); - let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind); - marks_now = apply; - close = apply || cancel; - return; - } - Dialog::PageBoxes => { - let (apply, cancel) = crate::pageboxes::body(ui, app, &t); - boxes_now = apply; - close = apply || cancel; - return; - } - Dialog::Protect => { - let (apply, cancel) = crate::protect::body(ui, app, &t); - protect_now = apply; - close = apply || cancel; - return; - } - Dialog::NumberPages => { - use pdfcraft_engine::LabelStyle as L; - ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((_, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let d = &mut app.number_draft; - d.to = d.to.clamp(1, n); - d.from = d.from.clamp(1, d.to); - egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Pages")); - ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); - }); - ui.end_row(); - ui.label(tl!("Style")); - let styles = [ - (L::Decimal, "1, 2, 3"), - (L::LowerRoman, "i, ii, iii"), - (L::UpperRoman, "I, II, III"), - (L::LowerAlpha, "a, b, c"), - (L::UpperAlpha, "A, B, C"), - (L::None, tl!("None (prefix only)")), - ]; - let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); - craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { - for (s, l) in styles { - ui.selectable_value(&mut d.style, s, l); - } - }); - ui.end_row(); - let l = ui.label(tl!("Prefix")); - ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); - ui.end_row(); - ui.label(tl!("Start")); - ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); - ui.end_row(); - }); - d.to = d.to.max(d.from); - let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); - ui.add_space(8.0); - let preview = if d.from == d.to { - label(d.start) - } else { - format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) - }; - ui.label( - egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])) - .color(t.text_muted), - ); - number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); - } - Dialog::Recovery => { - ui.horizontal(|ui| { - ui.add(crate::icons::image("clock-3", 22.0, t.accent)); - ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); - }); - ui.add_space(6.0); - ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); - ui.add_space(8.0); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { - for m in &app.recoverable { - ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); - let mins = now.saturating_sub(m.saved_at) / 60; - let when = match mins { - 0 => tl!("just now").to_string(), - 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), - _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), - }; - let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; - ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); - ui.end_row(); - } - }); - } - Dialog::Shortcuts => { - ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); - ui.add_space(8.0); - use crate::commands::{ - ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, - ZOOM_OUT, - }; - // Registered commands first (always in sync with the real bindings), then the - // keys the document view handles itself. - let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS - .iter() - .filter_map(|c| { - c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string())) - }) - .collect(); - let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); - let pair = |a, b| format!("{} / {}", key(a), key(b)); - // Key names, translated where a catalog has them (not scanned as UI literals). - let named = |k: &str| tl!(k).to_string(); - let scroll = crate::i18n::fmt( - tl!("Zoom in / out (also pinch or {key}-scroll)"), - &[("key", crate::commands::command_modifier_label(ui.ctx()))], - ); - for (k, v) in [ - (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), - (key(COPY), tl!("Copy selected text").to_string()), - (named("Double-click"), tl!("Select a word").to_string()), - (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()), - (key(ACTUAL_SIZE), tl!("Actual size").to_string()), - (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), - (key(FIT_WIDTH), tl!("Fit to width").to_string()), - (pair(ZOOM_IN, ZOOM_OUT), scroll), - (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), - (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), - (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), - (named("V"), tl!("Select (V)").to_string()), - (named("H / Space (hold)"), tl!("Hand (H)").to_string()), - (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), - (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), - ] { - rows.push((k, v)); - } - egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { - egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { - for (k, v) in &rows { - ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); - ui.label(v); - ui.end_row(); - } - }); - }); - } - Dialog::About => { - // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal_wrapped(|ui| { - for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { - let i = i as u8; - if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { - tab = i; - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.horizontal(|ui| { - widgets::artcraft_mark(ui, 40.0); - ui.vertical(|ui| { - ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); - ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); - }); - }); - ui.add_space(6.0); - ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); - ui.label( - egui::RichText::new( - "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", - ) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); - widgets::artcraft_logo(ui, 16.0); - }); - ui.add_space(6.0); - if let Some(cmd) = widgets::community_links(ui) { - link_command = Some(cmd); - } - } - } + if dialog_body(ui, app, dialog, &mut req) { + dialog_footer(ui, app, dialog, &mut req); + } + }); + finish_dialog(app, dialog, modal.should_close(), &mut req); +} + +/// The dialog's content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + match dialog { + Dialog::WordCount => { + req.close = crate::word_count_ui::body(ui, app); + } + Dialog::CreateImages => { + let (go, cancel) = crate::create_ui::image_import_body(ui, app); + req.images_now = go; + req.close = go || cancel; + return false; + } + Dialog::Properties(tab) => { + if !dialog_properties(ui, app, req, tab) { + return false; } } - ui.add_space(16.0); - let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); - // One row high: on its own, a right-to-left layout centres the buttons in all the height - // left, which after a taller Properties tab is the room that tab left behind. + Dialog::Split => { + if !dialog_split(ui, app, req) { + return false; + } + } + Dialog::ReplacePages => { + if !dialog_replace_pages(ui, app, req) { + return false; + } + } + Dialog::RedactPages => { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::RedactSearch => { + let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); + if go { + req.redact_now = Some(dialog); + } + req.import_words = import; + req.close = cancel; + return false; + } + Dialog::RedactProps => { + if !dialog_redact_props(ui, app, req) { + return false; + } + } + Dialog::RedactApply => { + let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); + let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::LinkProps => { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.link_draft.as_mut() else { + req.close = true; + return false; + }; + let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); + if ok { + req.link_now = Some(crate::link_ui::edit_for(d)); + } + req.close = ok || cancel; + return false; + } + Dialog::Extract => { + if !dialog_extract(ui, app, req) { + return false; + } + } + Dialog::RotatePages => { + if !dialog_rotate_pages(ui, app, req) { + return false; + } + } + Dialog::DuplicateField => { + if !dialog_duplicate_field(ui, app, req) { + return false; + } + } + // Never reached: `dialogs` shows this movable window above and returns before the modal. + // The arm keeps the match exhaustive. + Dialog::FieldTable => req.close = true, + Dialog::Optimize => { + let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); + req.optimize_now = ok; + req.close = ok || cancel; + if std::mem::take(&mut app.optimize_draft.audit) { + app.refresh_space_audit(); + req.next = Some(Dialog::AuditSpace); + } + return false; + } + Dialog::CertificateViewer => { + if !dialog_certificate_viewer(ui, app, req) { + return false; + } + } + Dialog::AuditSpace => { + if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { + req.next = Some(Dialog::Optimize); + } + return false; + } + Dialog::Sign => { + if crate::sign_ui::dialog(ui, app, &t) { + app.sign_draft = None; + req.close = true; + } + return false; + } + Dialog::SummarizeComments => { + if !dialog_summarize_comments(ui, app, req) { + return false; + } + } + Dialog::Revert => { + if !dialog_revert(ui, app, req) { + return false; + } + } + Dialog::OverwriteChanged => { + if !dialog_overwrite_changed(ui, app, req) { + return false; + } + } + Dialog::Print => { + if !dialog_print(ui, app, req) { + return false; + } + } + Dialog::RemoveHidden => { + let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::Sanitize => { + let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::FieldProps => { + let Some(d) = app.field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::prepare::body(ui, d, &t); + req.apply = go; + req.field_props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::BulkFieldProps => { + let Some(d) = app.bulk_field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::bulk_fields::body(ui, d, &t); + req.apply = go; + req.bulk_field_props_now = req.apply; + req.close = cancel; + return false; + } + Dialog::CommentProps => { + let (go, cancel) = crate::comment_props::body(ui, app, &t); + req.apply = go; + req.props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Signature => { + if !dialog_signature(ui, app, req) { + return false; + } + } + Dialog::AltText => { + let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); + req.alt_now = save; + req.close = save || cancel; + return false; + } + Dialog::CreateStamp => { + let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); + req.stamp_now = save; + req.close = save || cancel; + return false; + } + Dialog::PdfA => { + req.close = crate::standards_ui::body(ui, app, &t); + return false; + } + Dialog::ActionWizard => { + ui.set_width(620.0); + req.close = crate::actions_ui::body(ui, app, &t); + return false; + } + Dialog::CompareFiles => { + let (go, cancel) = crate::compare_ui::body(ui, app, &t); + req.compare_now = go; + req.close = go || cancel; + return false; + } + Dialog::JsConsole => { + ui.set_width(640.0); + req.close = crate::js_ui::console_body(ui, app, &t); + return false; + } + Dialog::DocumentJs => { + ui.set_width(640.0); + req.close = crate::js_ui::document_js_body(ui, app, &t); + return false; + } + Dialog::Preferences => { + if !dialog_preferences(ui, app, req) { + return false; + } + } + Dialog::RecognizeText => { + let (go, cancel) = crate::ocr_ui::body(ui, app, &t); + req.ocr_now = go; + req.close = go || cancel; + return false; + } + Dialog::AccessibilityOptions => { + let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); + req.a11y_now = start; + req.close = start || cancel; + return false; + } + Dialog::Export(kind) => { + let (go, cancel) = crate::export_ui::body(ui, app, &t, kind); + req.apply = go; + req.export_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Marks(kind) => { + ui.set_width(720.0); + let (go, cancel) = crate::marks_ui::body(ui, app, &t, kind); + req.apply = go; + req.marks_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::PageBoxes => { + let (go, cancel) = crate::pageboxes::body(ui, app, &t); + req.apply = go; + req.boxes_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Protect => { + let (go, cancel) = crate::protect::body(ui, app, &t); + req.apply = go; + req.protect_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::NumberPages => { + if !dialog_number_pages(ui, app, req) { + return false; + } + } + Dialog::Recovery => { + if !dialog_recovery(ui, app) { + return false; + } + } + Dialog::Shortcuts => { + if !dialog_shortcuts(ui) { + return false; + } + } + Dialog::About => { + if !dialog_about(ui, req) { + return false; + } + } + } + true +} + +/// `Dialog::Properties(tab)` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending, tab: PropsTab) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + for (tb, label) in [ + (PropsTab::Description, tl!("Description")), + (PropsTab::InitialView, tl!("Initial View")), + (PropsTab::Security, tl!("Security")), + (PropsTab::Fonts, tl!("Fonts")), + (PropsTab::Custom, tl!("Custom")), + (PropsTab::Advanced, tl!("Advanced")), + (PropsTab::SpaceAudit, tl!("Space Audit")), + ] { + if widgets::mode_tab(ui, label, tab == tb).clicked() { + req.next = Some(Dialog::Properties(tb)); + } + } + }); + ui.separator(); + let units = app.units; + let Some((_, id)) = app.active_ids() else { return false }; + let Some(doc) = app.session.get(id) else { return false }; + let i = &doc.info; + // Description shows plain values among text fields; they start where the fields' text does. + let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); + let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; + let row = |ui: &mut egui::Ui, k: &str, v: String| { + // Row labels are UI chrome; values are document data and stay as they are. + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if dialog == Dialog::Recovery { - if widgets::pill_button(ui, tl!("Recover"), true).clicked() { - recover = Some(true); - close = true; + ui.add_space(inset); + ui.add(egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap()); + }); + ui.end_row(); + }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; + egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + if tab == PropsTab::SpaceAudit { + ui.add_space(4.0); + crate::optimize_ui::audit_table(ui, &app.space_audit, &t); + return; + } + egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { + PropsTab::Description => { + path_row(ui, "File", doc.name.clone()); + match app.props_draft.as_mut() { + Some((_, draft)) if editable_info => { + for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { + let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + // Subject and Keywords are often a sentence or a list: they wrap and grow + // instead of hiding most of the value past the field's edge. + let field = if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; + ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); + ui.end_row(); + } } - if widgets::pill_button(ui, tl!("Discard"), false).clicked() { - recover = Some(false); - close = true; + _ => { + row(ui, "Title", i.title.clone().unwrap_or_default()); + row(ui, "Author", i.author.clone().unwrap_or_default()); + row(ui, "Subject", i.subject.clone().unwrap_or_default()); + row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); } - } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply_number = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { - split_now = split_ready.clone(); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if changed { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { - close = true; } - }) + row(ui, "Application", i.creator.clone().unwrap_or_default()); + row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); + } + PropsTab::InitialView => { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let editable = doc.allows_modification(); + let pages = i.pages.len(); + let Some((_, v)) = app.view_draft.as_mut() else { return }; + ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); + ui.end_row(); + let label = ui.label(tl!("Navigation tab")); + ui.add_enabled_ui(editable, |ui| { + craft_ui::ComboBox::from_id_salt("iv-nav") + .selected_text(tl!(format!("{:?}", v.navigation) + .replace("PageOnly", "Page Only") + .replace("Pages", "Pages Panel and Page") + .as_str())) + .show_ui(ui, |ui| { + for (n, l) in [ + (N::PageOnly, tl!("Page Only")), + (N::Bookmarks, tl!("Bookmarks Panel and Page")), + (N::Pages, tl!("Pages Panel and Page")), + (N::Attachments, tl!("Attachments Panel and Page")), + (N::Layers, tl!("Layers Panel and Page")), + ] { + ui.selectable_value(&mut v.navigation, n, l); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Page layout")); + ui.add_enabled_ui(editable, |ui| { + let names = [ + (L::Default, tl!("Default")), + (L::SinglePage, tl!("Single Page")), + (L::SinglePageContinuous, tl!("Single Page Continuous")), + (L::TwoUp, tl!("Two-Up (Facing)")), + (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), + (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), + (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), + ]; + let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); + craft_ui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Magnification")); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + let names = [ + (M::Default, tl!("Default")), + (M::ActualSize, tl!("Actual Size")), + (M::FitPage, tl!("Fit Page")), + (M::FitWidth, tl!("Fit Width")), + (M::FitHeight, tl!("Fit Height")), + (M::FitVisible, tl!("Fit Visible")), + ]; + let shown = match v.magnification { + M::Percent(p) => format!("{p:.0}%"), + m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), + }; + craft_ui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }) + .response + .labelled_by(label.id); + }); + }); + ui.end_row(); + let label = ui.label(tl!("Open to page")); + ui.add_enabled_ui(editable, |ui| { + let mut p = v.page + 1; + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { + v.page = p - 1; + } + ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); + }); + ui.end_row(); + ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); + ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); + ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); + ui.horizontal(|ui| { + ui.label(tl!("Show:")); + widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); + widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); + }); + }); + }); + ui.end_row(); + ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); + ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); + ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); + }); + }); + ui.end_row(); + } + PropsTab::Security => match doc.security_summary() { + None => { + row(ui, "Security method", tl!("No security").to_string()); + row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); + if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { + req.link_command = Some("protect.password"); + } + } + Some(sec) => { + row(ui, "Security method", tl!("Password security").to_string()); + row(ui, "Encryption", sec.method.clone()); + row( + ui, + "Opened with", + if sec.pending { + tl!("— (protection is applied when you save)").to_string() + } else if sec.owner { + tl!("Owner password (no restrictions req.apply)").to_string() + } else { + tl!("User password").to_string() + }, + ); + // A restricted document asks for its permissions password first. + if doc.allows_security_change() || doc.can_unlock() { + ui.horizontal(|ui| { + if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { + req.link_command = Some("protect.unlock"); + } + if ui.button(tl!("Change settings…")).clicked() { + req.link_command = Some("protect.password"); + } + if ui.button(tl!("Remove security")).clicked() { + req.link_command = Some("protect.remove"); + } + }); + } + let p = sec.permissions; + let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); + row( + ui, + "Printing", + if !p.print() { + tl!("Not allowed").to_string() + } else if p.print_high_quality() { + tl!("High resolution").to_string() + } else { + tl!("Low resolution").to_string() + }, + ); + row(ui, "Changing the document", yes(p.modify())); + row(ui, "Document assembly", yes(p.assemble())); + row(ui, "Content copying", yes(p.copy())); + row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); + row(ui, "Commenting", yes(p.annotate())); + row(ui, "Filling of form fields", yes(p.fill_forms())); + } + }, + PropsTab::Fonts => { + if i.fonts.is_empty() { + row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); + } + for f in &i.fonts { + let mut detail = f.kind.clone(); + if let Some(e) = &f.encoding { + detail.push_str(&format!(" · {e}")); + } + detail.push_str(if f.subset { + " · Embedded subset" + } else if f.embedded { + " · Embedded" + } else { + " · Not embedded (substituted)" + }); + row(ui, &f.name, detail); + } + } + PropsTab::Custom => { + // Add takes a new name, Update changes the value of the listed property the + // Name field names, Delete removes it; OK writes the changes as one step. + let editable = doc.allows_modification(); + let Some((_, d)) = app.custom_draft.as_mut() else { return }; + let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name")) + .labelled_by(l.id); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value")) + .labelled_by(l.id); + ui.end_row(); + // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. + let name = d.name.clone(); + let value = d.value.trim().to_owned(); + let listed = d.props.iter().position(|(k, _)| *k == name); + // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. + let file_props = doc.custom_info(); + let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); + let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); + ui.label(""); + // A file can hold a custom name that `check_custom_key` refuses, such as one + // with a space: the tab lists it, and Add, Update and Delete stay off for it. + let why = |b: egui::Response| match &refused { + Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), + None => b, + }; + // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. + let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); + // `custom_info` lists at most 256 properties, and a new name goes after the file's + // entries, so Add is off once the draft or the file's own list holds 256. + let room = d.props.len() < 256 && file_props.len() < 256; + ui.horizontal(|ui| { + let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); + if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { + d.props.push((name.clone(), value.clone())); + d.name.clear(); + d.value.clear(); + } + let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); + if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() + && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) + { + p.1 = value.clone(); + } + if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() + && let Some(n) = listed.filter(|n| *n < d.props.len()) + { + d.props.remove(n); + d.name.clear(); + d.value.clear(); + } + }); + ui.end_row(); + if d.props.is_empty() { + ui.label(""); + ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); + ui.end_row(); + } + // A click on a property fills the fields with it; values are document data. + let mut picked = None; + for (k, v) in &d.props { + if ui.selectable_label(*k == name, k.as_str()).clicked() { + picked = Some((k.clone(), v.clone())); + } + ui.scope(|ui| { + ui.set_max_width(420.0); + ui.add(egui::Label::new(v.as_str()).wrap()); + }); + ui.end_row(); + } + if let Some((k, v)) = picked { + d.name = k; + d.value = v; + } + } + PropsTab::Advanced => { + row(ui, "PDF version", i.pdf_version.clone()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); + row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); + let p = &i.pages[0]; + row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); + row(ui, "Number of pages", i.pages.len().to_string()); + row(ui, "Tagged PDF", yes(i.tagged)); + row(ui, "Form fields", i.fields.len().to_string()); + row(ui, "Comments", i.annotations.len().to_string()); + row(ui, "Layers", i.layers.len().to_string()); + row(ui, "Attachments", i.attachments.len().to_string()); + row(ui, "JavaScript", yes(i.has_javascript)); + // Each incremental update is a revision; earlier ones open as their own document. + let ends = doc.revision_ends(); + if ends.len() > 1 { + ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + req.open_revision = Some(n); + } + } + }); + }); + ui.end_row(); + } + // Reading Options: binding and language. + if let Some((_, v)) = app.view_draft.as_mut() { + let editable = doc.allows_modification(); + ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); + widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); + }); + }); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); + let mut lang = v.language.clone().unwrap_or_default(); + if ui + .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) + .labelled_by(l.id) + .changed() + { + v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); + } + ui.end_row(); + } + // What was repaired while reading a damaged file (fidelity: never silent). + let repairs = doc.repair_log(); + row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); + if !repairs.is_empty() { + ui.label(""); + egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { + for r in &repairs { + ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); + } + }); + ui.end_row(); + } + } + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} }); }); - if let Some(yes) = recover { + true +} + +/// `Dialog::Split` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_split(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((vi, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); + let marks = app.session.bookmark_splits(id); + let draft = &mut app.split_draft; + use crate::SplitMode as M; + if selected.is_empty() && draft.mode == M::Selection { + draft.mode = M::Pages; + } + widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); + ui.add_enabled_ui(draft.mode == M::Pages, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("Pages per file")); + ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); + }); + }); + widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); + ui.add_enabled_ui(draft.mode == M::Size, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("At most")); + ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); + }); + }); + ui.add_enabled_ui(!marks.is_empty(), |ui| { + widgets::radio_value( + ui, + &mut draft.mode, + M::Bookmarks, + crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), + ) + }); + ui.add_enabled_ui(!selected.is_empty(), |ui| { + widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) + }); + let plan = match draft.mode { + M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), + M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), + M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), + M::Bookmarks => crate::SplitPlan::Bookmarks, + }; + let files = match &plan { + crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), + crate::SplitPlan::Bookmarks => { + Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) + } + crate::SplitPlan::Size(_) => None, + }; + ui.add_space(8.0); + let text = match files { + Some(f) => { + if f == 1 { + crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) + } else { + crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) + } + } + None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), + }; + ui.label(egui::RichText::new(text).color(t.text_muted)); + if files.is_none_or(|f| f > 1) { + req.split_ready = Some(plan); + } + true +} + +/// `Dialog::ReplacePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_replace_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let Some(d) = app.replace_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + d.to = d.to.clamp(1, count); + d.from = d.from.clamp(1, d.to); + ui.horizontal(|ui| { + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); + if from.changed() || to.changed() { + d.range_edited = true; + } + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); + }); + let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; + let max_start = d.src_pages.saturating_sub(n) + 1; + d.src_from = d.src_from.clamp(1, max_start.max(1)); + ui.horizontal(|ui| { + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); + ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); + }); + let fits = n <= d.src_pages; + ui.add_space(6.0); + ui.label( + egui::RichText::new(if fits { + tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") + } else { + tl!("The replacement file doesn't have that many pages.") + }) + .small() + .color(t.text_faint), + ); + ui.add_space(10.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { + req.replace_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RedactProps` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_redact_props(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let mut prefs = app.redact_prefs.clone(); + let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); + app.redact_prefs = prefs; + if ok && app.redact_prefs.remember { + app.redact_defaults = app.redact_prefs.clone(); + } + if cancel && let Some(before) = app.redact_before.take() { + app.redact_prefs = before; + } + if ok { + app.redact_before = None; + } + req.close = ok || cancel; + false +} + +/// `Dialog::Extract` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_extract(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); + ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, + }; + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } + ui.add_space(4.0); + // The name the pages are saved under (#737); separate files add " (page N)". + ui.horizontal(|ui| { + let l = ui.label(tl!("File name")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); + }); + ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); + ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { + req.extract_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RotatePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + use pdfcraft_engine::{PageOrientation as O, PageParity as P}; + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let d = &mut app.rotate_draft; + ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Direction:")); + craft_ui::ComboBox::from_id_salt("rotate-dir") + .selected_text(match d.degrees { + 270 => tl!("Counterclockwise 90 degrees"), + 180 => tl!("180 degrees"), + _ => tl!("Clockwise 90 degrees"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); + }); + ui.end_row(); + ui.label(tl!("Pages:")); + ui.vertical(|ui| { + widgets::radio_value(ui, &mut d.which, 0, tl!("All")); + widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.which, 2, tl!("From")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + }); + ui.end_row(); + ui.label(tl!("Rotate:")); + craft_ui::ComboBox::from_id_salt("rotate-parity") + .selected_text(match d.parity { + P::Both => tl!("Even and Odd Pages"), + P::Even => tl!("Even Pages Only"), + P::Odd => tl!("Odd Pages Only"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); + ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); + ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); + }); + ui.end_row(); + ui.label(""); + craft_ui::ComboBox::from_id_salt("rotate-orient") + .selected_text(match d.orientation { + O::Both => tl!("Landscape and Portrait Pages"), + O::Landscape => tl!("Landscape Pages"), + O::Portrait => tl!("Portrait Pages"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); + ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); + ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); + }); + ui.end_row(); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.rotate_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::DuplicateField` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.duplicate_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); + widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.all, false, tl!("From")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; + req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::CertificateViewer` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_certificate_viewer(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.set_width(700.0); + let trusted = app.session.trusted_certificates().to_vec(); + let Some(v) = app.cert_viewer.as_mut() else { + req.close = true; + return false; + }; + let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); + req.close = done; + match action { + Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), + Some(crate::sign_ui::CertAction::Export(c)) => { + app.export_certificate(&c); + } + None => {} + } + false +} + +/// `Dialog::SummarizeComments` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(tl!("Choose a layout:")); + let _ = ui.add(widgets::radio(true, tl!("Comments only"))); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Sort comments by:")); + craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { + for s in pdfcraft_engine::SummarySort::ALL { + ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); + } + }); + }); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { + req.summarize_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Revert"), true).clicked() { + req.revert_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::OverwriteChanged` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_overwrite_changed(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + #[cfg(not(target_arch = "wasm32"))] + let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let name = String::new(); + ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { + req.overwrite_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { + req.overwrite_elsewhere = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Print` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let Some((i, id)) = app.active_ids() else { + req.close = true; + return false; + }; + if app.print_draft.handling == crate::print_ui::Handling::Label { + app.read_label_crops(id); + } + let Some(doc) = app.session.get(id) else { return false }; + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let rasters = crate::print_ui::preview_rasters( + &app.print_draft, + &sizes, + &labels, + ui.ctx().pixels_per_point(), + ui.ctx().input(|i| i.max_texture_side) as f32, + ); + let view = &mut app.views[i]; + view.set_print_grayscale(app.print_draft.grayscale); + view.queue_print_previews(&rasters); + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { + view.need_thumbnail(p, true); + view.page_preview(p) + }); + req.print_go = go; + req.close = go || cancel; + false +} + +/// `Dialog::Signature` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let (go, cancel, browse) = + crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); + req.apply = go; + if browse { + app.pick_signature_image(); + } + if req.apply { + let d = std::mem::take(&mut app.signature_draft); + let tool = if d.initials { + app.initials = d.saved(); + crate::fill_sign::FillTool::Initials + } else { + app.signature = d.saved(); + crate::fill_sign::FillTool::Signature + }; + app.quick_tool = crate::QuickTool::Fill(tool); + app.toast = None; + } + req.close = req.apply || cancel; + false +} + +/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // The settings outgrow a short window: scroll them rather than run off the screen. + // The OK row stays below the scroll, in reach however far the settings are scrolled. + let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); + // A scroll area starts from the room the dialog offers it, and a modal offers more + // each frame for the first few, so with OK outside the scroll the dialog would grow + // over those frames. Asking for the full height as the least it may scroll in + // (which only applies when the settings overflow it) opens the dialog at full size. + // A scrollbar of its own: the floating one lies over the right edge of the theme + // cards and other full-width rows (#3557). + ui.scope(|ui| { + ui.spacing_mut().scroll.floating = false; + egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); + }); + req.close = crate::js_ui::preferences_buttons(ui); + false +} + +/// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + use pdfcraft_engine::LabelStyle as L; + ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((_, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let d = &mut app.number_draft; + d.to = d.to.clamp(1, n); + d.from = d.from.clamp(1, d.to); + egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Pages")); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); + }); + ui.end_row(); + ui.label(tl!("Style")); + let styles = [ + (L::Decimal, "1, 2, 3"), + (L::LowerRoman, "i, ii, iii"), + (L::UpperRoman, "I, II, III"), + (L::LowerAlpha, "a, b, c"), + (L::UpperAlpha, "A, B, C"), + (L::None, tl!("None (prefix only)")), + ]; + let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); + craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + for (s, l) in styles { + ui.selectable_value(&mut d.style, s, l); + } + }); + ui.end_row(); + let l = ui.label(tl!("Prefix")); + ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); + ui.end_row(); + ui.label(tl!("Start")); + ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); + ui.end_row(); + }); + d.to = d.to.max(d.from); + let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); + ui.add_space(8.0); + let preview = if d.from == d.to { + label(d.start) + } else { + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + }; + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted), + ); + req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + true +} + +/// `Dialog::Recovery` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_recovery(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(crate::icons::image("clock-3", 22.0, t.accent)); + ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); + }); + ui.add_space(6.0); + ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); + ui.add_space(8.0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { + for m in &app.recoverable { + ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); + let mins = now.saturating_sub(m.saved_at) / 60; + let when = match mins { + 0 => tl!("just now").to_string(), + 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), + _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), + }; + let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; + ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); + ui.end_row(); + } + }); + true +} + +/// `Dialog::Shortcuts` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { + ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); + ui.add_space(8.0); + use crate::commands::{ + ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT, + }; + // Registered commands first (always in sync with the real bindings), then the + // keys the document view handles itself. + let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS + .iter() + .filter_map(|c| c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string()))) + .collect(); + let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); + let pair = |a, b| format!("{} / {}", key(a), key(b)); + // Key names, translated where a catalog has them (not scanned as UI literals). + let named = |k: &str| tl!(k).to_string(); + let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]); + for (k, v) in [ + (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), + (key(COPY), tl!("Copy selected text").to_string()), + (named("Double-click"), tl!("Select a word").to_string()), + (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / req.close find").to_string()), + (key(ACTUAL_SIZE), tl!("Actual size").to_string()), + (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), + (key(FIT_WIDTH), tl!("Fit to width").to_string()), + (pair(ZOOM_IN, ZOOM_OUT), scroll), + (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), + (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), + (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), + (named("V"), tl!("Select (V)").to_string()), + (named("H / Space (hold)"), tl!("Hand (H)").to_string()), + (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), + (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), + ] { + rows.push((k, v)); + } + egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { + egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { + for (k, v) in &rows { + ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); + ui.label(v); + ui.end_row(); + } + }); + }); + true +} + +/// `Dialog::About` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // Tabs About · Contributors · Models (craftrules standards/contributors.md). + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal_wrapped(|ui| { + for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { + let i = i as u8; + if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { + tab = i; + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.horizontal(|ui| { + widgets::artcraft_mark(ui, 40.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); + ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); + }); + }); + ui.add_space(6.0); + ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); + ui.label( + egui::RichText::new( + "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", + ) + .color(t.text_muted) + .small(), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); + widgets::artcraft_logo(ui, 16.0); + }); + ui.add_space(6.0); + if let Some(cmd) = widgets::community_links(ui) { + req.link_command = Some(cmd); + } + } + } + true +} + +fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + ui.add_space(16.0); + let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); + // One row high: on its own, a right-to-left layout centres the buttons in all the height + // left, which after a taller Properties tab is the room that tab left behind. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dialog == Dialog::Recovery { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { + req.recover = Some(true); + req.close = true; + } + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { + req.recover = Some(false); + req.close = true; + } + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply_number = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if dialog == Dialog::Split { + if ui.add_enabled_ui(req.split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { + req.split_now = req.split_ready.clone(); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if changed { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + req.close = true; + } + }) + }); +} + +/// Acts on what the dialog asked for, once the modal is drawn. +fn finish_dialog(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + finish_requests(app, dialog, modal_closed, req); + finish_actions(app, req); + finish_redactions_and_fields(app, req); + finish_edits(app, dialog, req); + finish_close(app, dialog, modal_closed, req); +} + +/// Runs the one-shot requests the dialog made. +fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + if let Some(yes) = req.recover { let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); if yes { app.recover(&keys); @@ -1426,7 +1593,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } } // The pages to replace belong to the document the dialog was opened on (#167). - if replace_now + if req.replace_now && let Some(d) = app.replace_draft.take() && app.still_pick_target(d.target) { @@ -1440,52 +1607,60 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); } // A print or save that fails keeps the dialog open, with the reason in a notice. - if print_go && !app.print_now() { - close = false; + if req.print_go && !app.print_now() { + req.close = false; } - if revert_now { + if req.revert_now { app.revert_active(); } #[cfg(not(target_arch = "wasm32"))] { - if overwrite_now { + if req.overwrite_now { app.overwrite_changed_file(); } - if overwrite_elsewhere { + if req.overwrite_elsewhere { app.save_changed_file_elsewhere(); } } #[cfg(target_arch = "wasm32")] - let _ = (overwrite_now, overwrite_elsewhere); + let _ = (req.overwrite_now, req.overwrite_elsewhere); // Cancelled or dismissed: the save is dropped. #[cfg(not(target_arch = "wasm32"))] - if dialog == Dialog::OverwriteChanged && (modal.should_close() || close) { + if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) { app.pending_overwrite = None; } - if summarize_now { +} + +/// Runs the document actions the dialog asked for. +fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { + if req.summarize_now { app.summarize_comments(); } - if optimize_now { + if req.optimize_now { app.optimize_with_draft(); } - if let Some(e) = duplicate_now { + if let Some(e) = req.duplicate_now.take() { app.duplicate_draft = None; app.apply_edit(e); } - if extract_now { + if req.extract_now { app.extract_selection(); } - if let Some(e) = link_now { + if let Some(e) = req.link_now.take() { app.apply_edit(e); app.link_draft = None; } - if rotate_now { + if req.rotate_now { app.rotate_with_draft(); } - if import_words { + if req.import_words { app.pick_files(crate::files::FilePurpose::RedactWords, false); } - match redact_now { +} + +/// Redaction, hidden-data and field-properties requests. +fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { + match req.redact_now.take() { Some(Dialog::RedactPages) => app.redact_pages(), Some(Dialog::RedactSearch) => { let n = app.redact_search(); @@ -1517,10 +1692,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } _ => {} } - if bulk_field_props_now { - close = app.apply_bulk_field_props(); + if req.bulk_field_props_now { + req.close = app.apply_bulk_field_props(); } - if field_props_now + if req.field_props_now && let Some(d) = app.field_props.take() && let Some(props) = d.props() { @@ -1549,7 +1724,13 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.views[i].prepare.selected = Some((name, d.widget)); } } - if props_now && let Some(d) = app.comment_props.take() { +} + +/// Edits built from the dialog's drafts. +fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + if req.props_now + && let Some(d) = app.comment_props.take() + { let mut edits = crate::comment_props::edits(&d); match edits.len() { 0 => {} @@ -1565,68 +1746,82 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.make_props_default(d.page, d.index, &d.edited); } } - if export_now && let Dialog::Export(kind) = dialog { + if req.export_now + && let Dialog::Export(kind) = dialog + { app.start_export(kind); } - if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) { + if req.marks_now + && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = crate::marks_ui::edit(&app.marks_draft, kind, count); app.apply_edit(edit); } - if boxes_now && let Some((i, id)) = app.active_ids() { + if req.boxes_now + && let Some((i, id)) = app.active_ids() + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = app.boxes_draft.edit(app.views[i].current, count); app.apply_edit(edit); app.boxes_draft.seeded = None; } - if protect_now && app.apply_edit(app.protect_draft.edit()) { + if req.protect_now && app.apply_edit(app.protect_draft.edit()) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } - if apply_number && let Some(edit) = number_now { + if req.apply_number + && let Some(edit) = req.number_now.take() + { app.apply_edit(edit); } - if let Some(by) = split_now { + if let Some(by) = req.split_now.take() { app.split_active(&by); } - if apply && let Some(edits) = draft_changes(app) { + if req.apply + && let Some(edits) = draft_changes(app) + { app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); } +} + +/// Closes (or switches) the dialog, then runs what closing it asked for. +fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { // Protect / Remove security replace the Properties dialog. - let replaces = link_command.is_some_and(|c| c.starts_with("protect.")) || open_revision.is_some(); - if modal.should_close() || close || replaces { + let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); + if modal_closed || req.close || replaces { app.dialog = None; app.props_draft = None; app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; } else { - app.dialog = Some(next); + app.dialog = Some(req.next.unwrap_or(dialog)); } - if let Some(cmd) = link_command { + if let Some(cmd) = req.link_command { app.execute(cmd); } - if let Some(n) = open_revision { + if let Some(n) = req.open_revision { app.open_revision(n); } - if alt_now { + if req.alt_now { app.save_alt_text(); } - if stamp_now { + if req.stamp_now { app.save_custom_stamp(); } - if images_now { + if req.images_now { app.finish_image_import(); } else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) { app.image_import = None; } - if ocr_now { + if req.ocr_now { app.start_ocr(); } - if compare_now { + if req.compare_now { app.run_compare(); } - if a11y_now { + if req.a11y_now { if let Some((_, id)) = app.active_ids() { app.a11y_skipped.retain(|(doc, _)| *doc != id); } diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 557ddd3b8f..fff7eea9a9 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, CloseRequest, Layout, PdfCraftApp, Tokens, theme, widgets}; /// The buttons of the "Save changes?" prompt, in the order they sit on screen, left to right. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] From 2d27306a8d3a655e0e89c444a19199d8722d8872 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 075/120] Refactor ui support for strict Clippy checks --- crates/ui-egui/src/app/input.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 25 +++ crates/ui-egui/src/app/window.rs | 2 +- crates/ui-egui/src/control/host.rs | 2 +- crates/ui-egui/src/control/pending.rs | 2 +- crates/ui-egui/src/control/plugin.rs | 2 +- crates/ui-egui/src/control/requests.rs | 2 +- crates/ui-egui/src/control/screenshot.rs | 2 +- crates/ui-egui/src/control/transport.rs | 18 +- crates/ui-egui/src/editing.rs | 157 +++++++++--------- crates/ui-egui/src/editing/close.rs | 2 +- crates/ui-egui/src/editing/save.rs | 2 +- crates/ui-egui/src/panels/bookmarks.rs | 2 +- crates/ui-egui/src/panels/edit.rs | 5 +- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/panels/tools.rs | 5 +- .../ui-egui/src/platform/open_worker/tests.rs | 140 +++++++++++----- crates/ui-egui/tests/it/signing.rs | 89 +++++++--- 19 files changed, 296 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index ad554c1dfd..953eb4a6eb 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GridDrop, GridFile, OpenRequest, PdfCraftApp, open_worker}; /// Read a file dropped on a page grid (see [`GridFile`]). fn read_grid_file((name, source): GridFile) -> Result<(String, Vec), (String, String)> { diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index ac8ac644ee..f64ba6d892 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -178,6 +178,15 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + self.restore_layout_prefs(&v); + self.restore_behavior_prefs(&v); + self.restore_view_prefs(&v); + self.restore_comment_prefs(&v); + self.restore_signature_prefs(&v); + } + + /// The panels, combine view, zoom and recent files. + fn restore_layout_prefs(&mut self, v: &serde_json::Value) { if let Some(on) = v["reopen_last_session"].as_bool() { self.reopen_last_session = on; } @@ -211,6 +220,10 @@ impl PdfCraftApp { if let Some((layout, continuous)) = v["default_layout"].as_str().and_then(canvas::parse_display_token) { (self.view_defaults.layout, self.view_defaults.continuous) = (layout, continuous); } + } + + /// Switches for reopening, window chrome and file watching. + fn restore_behavior_prefs(&mut self, v: &serde_json::Value) { if let Some(show) = v["default_page_gaps"].as_bool() { self.view_defaults.show_page_gaps = show; } @@ -252,6 +265,10 @@ impl PdfCraftApp { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; } + } + + /// Display defaults, units, language and sign-in details. + fn restore_view_prefs(&mut self, v: &serde_json::Value) { // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) // fails to deserialize and keeps Off. if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { @@ -277,6 +294,10 @@ impl PdfCraftApp { if let Some(pinned) = v["comment_tool_pinned"].as_bool() { self.comment_prefs.pinned = pinned; } + } + + /// The author and per-tool comment styles. + fn restore_comment_prefs(&mut self, v: &serde_json::Value) { // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -305,6 +326,10 @@ impl PdfCraftApp { } } } + } + + /// Saved signatures, stamps, actions and trusted certificates. + fn restore_signature_prefs(&mut self, v: &serde_json::Value) { if let Ok(s) = serde_json::from_value::>>(v["signature"].clone()) && s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite()))) { diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index c950b54a55..e7035b5000 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, QuickTool}; impl PdfCraftApp { /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 62693cda2e..1166c0e7ec 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Host, Reply, Value, json}; impl Host for crate::PdfCraftApp { /// Cancel any in-progress pointer gesture in every open view. diff --git a/crates/ui-egui/src/control/pending.rs b/crates/ui-egui/src/control/pending.rs index 9e63849cf0..d87ae037a9 100644 --- a/crates/ui-egui/src/control/pending.rs +++ b/crates/ui-egui/src/control/pending.rs @@ -1,5 +1,5 @@ use super::screenshot::{Geometry, MAX_IMAGE_BYTES, retried_reservation, screenshot_png}; -use super::*; +use super::{Arc, Control, Host, Pending, Reply, Sender, Value, not_drawn, timeout}; /// A screenshot pending in the `finish_pending` pass, with ownership of its reply. struct PendingCapture { diff --git a/crates/ui-egui/src/control/plugin.rs b/crates/ui-egui/src/control/plugin.rs index be0dff826e..a6def94c4f 100644 --- a/crates/ui-egui/src/control/plugin.rs +++ b/crates/ui-egui/src/control/plugin.rs @@ -1,5 +1,5 @@ use super::requests::Handled; -use super::*; +use super::{Arc, Control, Mutex, RequestBatches, Shared, Value, timeout}; /// Track only presses that reached egui and have not yet been released. A pointer release is /// retained as an ownership marker, never as a timeout event. diff --git a/crates/ui-egui/src/control/requests.rs b/crates/ui-egui/src/control/requests.rs index 1e2a24b979..0c926ab2ab 100644 --- a/crates/ui-egui/src/control/requests.rs +++ b/crates/ui-egui/src/control/requests.rs @@ -1,5 +1,5 @@ use super::screenshot::Geometry; -use super::*; +use super::{Action, Control, Host, NodeId, Reply, Value, accesskit, json}; impl Control { /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with diff --git a/crates/ui-egui/src/control/screenshot.rs b/crates/ui-egui/src/control/screenshot.rs index 9eb3e50bbd..c6fbb0ed83 100644 --- a/crates/ui-egui/src/control/screenshot.rs +++ b/crates/ui-egui/src/control/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Control, Host, Pending, Reply, Sender, json}; /// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. const MAX_PENDING_SCREENSHOTS: usize = 4; diff --git a/crates/ui-egui/src/control/transport.rs b/crates/ui-egui/src/control/transport.rs index 81e30ff108..23a5181de7 100644 --- a/crates/ui-egui/src/control/transport.rs +++ b/crates/ui-egui/src/control/transport.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlClient, Value, json}; // ---- transport (native only) ----------------------------------------------------------------- @@ -331,7 +331,7 @@ mod transport_tests { #[test] fn malformed_input_before_auth_closes_the_connection() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -375,7 +375,7 @@ mod transport_tests { #[test] fn an_overlong_line_before_auth_is_refused() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -429,7 +429,7 @@ mod transport_tests { #[test] fn an_unauthenticated_connection_is_closed_at_the_deadline() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); // Idle. let started = std::time::Instant::now(); @@ -468,7 +468,7 @@ mod transport_tests { #[test] fn a_client_refused_with_input_unread_gets_the_reason() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let s = connect(ep.port); let mut w = s.try_clone().unwrap(); @@ -488,7 +488,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); // Refused with most of its line unread, and left open: it lingers for `linger`. @@ -517,7 +517,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; // The first refused connection takes the only lingering place and keeps it. @@ -553,7 +553,7 @@ mod transport_tests { #[test] fn only_so_many_connections_may_wait_to_authenticate() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); let first = connect(ep.port); @@ -593,7 +593,7 @@ mod transport_tests { #[test] fn long_lines_after_auth_are_still_read() { use std::io::{BufRead, BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ba2d3cbf96..4d09b5cf4b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -27,6 +27,79 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Keeps the pages (or comment) the user acted on selected, where they now are. +fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) { + match edit { + Edit::MovePages { pages, to } => { + let n = pages.iter().collect::>().len(); + let to = to.min(info.pages.len() - n); + view.select_pages(&(to..to + n).collect::>()); + } + Edit::DuplicatePages { pages, at: Some(at) } => { + let n = pages.iter().collect::>().len(); + let at = at.min(info.pages.len().saturating_sub(n)); + view.select_pages(&(at..at + n).collect::>()); + } + Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), + Edit::DeletePages { .. } => view.select_pages(&[]), + // A stroke drawn with the pen stays unselected, so the selection box and + // author popup don't sit over the next stroke (#429). + Edit::AddAnnotation(a) + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) + && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + { + view.comments.selected = None; + } + Edit::AddAnnotation(a) => { + // Select the new comment (appended last among the page's comments). + let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.selected = newest.map(|n| (a.page, n)); + view.comments.reveal = true; + // A new highlight opens its note for typing straight away, as in Acrobat, + // unless a note typed into another card is still unsaved. + if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) + && a.contents.is_empty() + && view.comments.editing.is_none() + { + view.comments.open_editor(a.page, n, String::new()); + } + } + Edit::DeleteAnnotation { .. } => view.comments.selected = None, + _ => {} + } +} + +/// The pages whose widgets differ between two form states, and the (field, page) pairs whose +/// value changed and so must not be drawn from the baked page. +fn form_edit_pages( + before: &[pdfcraft_engine::FormField], + after: &[pdfcraft_engine::FormField], +) -> (std::collections::BTreeSet, Vec<(String, usize)>) { + let mut changed_pages = std::collections::BTreeSet::new(); + let mut unbaked = Vec::new(); + for f in after { + let pages = |field: &pdfcraft_engine::FormField| field.widgets.iter().filter_map(|w| w.page).collect::>(); + match before.iter().find(|old| old.name == f.name) { + Some(old) if old == f => {} + Some(old) => { + changed_pages.extend(pages(f)); + changed_pages.extend(pages(old)); + if old.value != f.value { + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + None => { + changed_pages.extend(pages(f)); + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + } + for old in before.iter().filter(|old| !after.iter().any(|f| f.name == old.name)) { + changed_pages.extend(old.widgets.iter().filter_map(|w| w.page)); + } + (changed_pages, unbaked) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -68,49 +141,8 @@ impl PdfCraftApp { && doc.xfa.is_none() && is_form_edit(&edit) { - let mut changed_pages = std::collections::BTreeSet::new(); - for f in doc.form.iter() { - match form_before.iter().find(|old| old.name == f.name) { - Some(old) => { - if old != f { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - if old.value != f.value { - for w in &f.widgets { - if let Some(p) = w.page { - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - None => { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - for old in form_before.iter() { - if !doc.form.iter().any(|f| f.name == old.name) { - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - } - } + let (changed_pages, unbaked) = form_edit_pages(&form_before, &doc.form); + view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } @@ -121,44 +153,7 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - match edit { - Edit::MovePages { pages, to } => { - let n = pages.iter().collect::>().len(); - let to = to.min(info.pages.len() - n); - view.select_pages(&(to..to + n).collect::>()); - } - Edit::DuplicatePages { pages, at: Some(at) } => { - let n = pages.iter().collect::>().len(); - let at = at.min(info.pages.len().saturating_sub(n)); - view.select_pages(&(at..at + n).collect::>()); - } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), - // A stroke drawn with the pen stays unselected, so the selection box and - // author popup don't sit over the next stroke (#429). - Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && self.quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => - { - view.comments.selected = None; - } - Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). - let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); - view.comments.selected = newest.map(|n| (a.page, n)); - view.comments.reveal = true; - // A new highlight opens its note for typing straight away, as in Acrobat, - // unless a note typed into another card is still unsaved. - if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) - && a.contents.is_empty() - && view.comments.editing.is_none() - { - view.comments.open_editor(a.page, n, String::new()); - } - } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, - _ => {} - } + select_after_edit(view, edit, info, self.quick_tool); if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 5317c0ccd0..55d884cd9a 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -1,5 +1,5 @@ +use super::PdfCraftApp; use super::save::SaveTarget; -use super::*; /// What the user was doing when we asked whether to save. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 59eba1fbc6..f685247c9d 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, StagingName, staging_suffixes}; /// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file /// on disk is another version than the document came from (#2166). diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index 3753c84940..ec61bf2fe2 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, Nav, OutlineItem, Rect, Sense, Tokens, icons, pos2, theme, vec2}; /// What the user asked to do with a bookmark (applied after the panel is drawn). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e10d0d01ee..fa4cc2536a 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1,5 +1,8 @@ use super::tools::row_label; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, Tokens, ToolGroup, ToolItem, catalog, icons, theme, + vec2, widgets, +}; /// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index e7afe45e85..1157b64a49 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, Align2, Color32, CornerRadius, DocInfo, Layout, Nav, Rect, Sense, Stroke, Tokens, icons, pos2, theme, vec2}; /// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, /// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 4f6c150c46..7b6502b9c0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1,6 +1,8 @@ use super::bookmarks::{BmAction, OutlineCtx, outline_item, outline_matches}; use super::pages::{page_tools, pages, thumbnail_size}; -use super::*; +use super::{ + Align, Align2, Color32, DocInfo, FieldKind, Layout, Nav, PdfCraftApp, Rect, RightPanel, Sense, Stroke, Tokens, human_size, icons, theme, vec2, +}; /// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 3a1cccd713..4ff76a7c43 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -1,5 +1,8 @@ use super::edit::{actions_menu, edit_panel, stamp_palette}; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, TOOL_GROUPS, Tokens, ToolGroup, catalog, icons, theme, + vec2, widgets, +}; const COLLAPSED_TOOLS: usize = 14; diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index af847b9a2e..fe2a0ae410 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -556,55 +556,117 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() { held.release.send(()).expect("release ordinary B"); held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B reply is also ready"); assert_eq!(harness.state().pending_opens.len(), 2); + let files = PickerFiles { + picked: &picked, + other: &other, + insert: &insert, + original_a: &original_a, + original_b: &original_b, + original_c: &original_c, + }; + let a_id = assert_picker_keeps_a_visible(&mut harness, &files); + assert_picker_completion_releases_b(&mut harness, a_id, cancel, &files); + } +} - // Poll admission separately from consuming the picker callback: this reproduces - // the same ordering as a UI frame, with both worker replies already available. - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); - assert_eq!(harness.state().active, Some(0)); - assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); - let a_id = harness.state().views[0].id; - let doc = harness.state().session.get(a_id).expect("picked A before picker completion"); - assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref())); +struct PickerFiles<'a> { + picked: &'a ToolOpenFile, + other: &'a ToolOpenFile, + insert: &'a ToolOpenFile, + original_a: &'a [u8], + original_b: &'a [u8], + original_c: &'a [u8], +} + +fn assert_picker_keeps_a_visible(harness: &mut Harness<'static, PdfCraftApp>, files: &PickerFiles<'_>) -> pdfcraft_engine::DocId { + // Poll admission separately from consuming the picker callback: this reproduces + // the same ordering as a UI frame, with both worker replies already available. + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); + assert_eq!(harness.state().active, Some(0)); + assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); + let a_id = harness.state().views[0].id; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "picked A before picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.picked.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 2); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(other.0.to_string_lossy().as_ref())); - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); - assert_eq!(harness.state().active, Some(0)); + } + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); + assert_eq!(harness.state().active, Some(0)); + a_id +} - // Consume the actual native-picker callback (or its empty-path cancellation). - harness.state_mut().process_picked(); - let doc = harness.state().session.get(a_id).expect("A after picker completion"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); +fn assert_picker_completion_releases_b( + harness: &mut Harness<'static, PdfCraftApp>, + a_id: pdfcraft_engine::DocId, + cancel: bool, + files: &PickerFiles<'_>, +) { + // Consume the actual native-picker callback (or its empty-path cancellation). + harness.state_mut().process_picked(); + assert_document_a_after_picker(harness, a_id, cancel); + assert_eq!(harness.state().views.len(), 1); + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().active, Some(0)); + + harness.step(); + assert!(harness.state().pending_opens.is_empty()); + assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); + let active = harness.state().active; + assert!(active.is_some(), "B activates after picker completion"); + if let Some(active) = active { + assert_document_b_after_picker(harness, active, files); + } + harness.run_steps(2); + assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); + assert_eq!(harness.state().views.len(), 2); + assert_eq!(harness.state().active, active); + assert_document_a_after_picker(harness, a_id, cancel); + assert_a_saved_source(harness, a_id, files.original_a); + assert_picker_files_unchanged(files); +} + +fn assert_document_a_after_picker(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, cancel: bool) { + let expected_pages = if cancel { 2 } else { 3 }; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A after picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.info.pages.len(), expected_pages); assert_eq!(doc.dirty, !cancel); assert_eq!(doc.can_undo().is_some(), !cancel); assert!(doc.can_redo().is_none()); - assert_eq!(harness.state().views.len(), 1); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().active, Some(0)); + } +} - harness.step(); - assert!(harness.state().pending_opens.is_empty()); - assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); - let active = harness.state().active.expect("B activates after picker completion"); - let doc = harness.state().session.get(harness.state().views[active].id).expect("ordinary B document"); - assert_eq!(doc.path.as_deref(), Some(other.0.to_string_lossy().as_ref())); +fn assert_document_b_after_picker(harness: &Harness<'static, PdfCraftApp>, active: usize, files: &PickerFiles<'_>) { + let doc = harness.state().session.get(harness.state().views[active].id); + assert!(doc.is_some(), "ordinary B document"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 1); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - harness.run_steps(2); - assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); - assert_eq!(harness.state().views.len(), 2); - assert_eq!(harness.state().active, Some(active)); - let doc = harness.state().session.get(a_id).expect("A retains exactly one insertion or cancellation"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); - assert_eq!(doc.dirty, !cancel); - assert_eq!(doc.can_undo().is_some(), !cancel); - assert!(doc.can_redo().is_none()); - assert_eq!(doc.saved_bytes().as_slice(), original_a.as_slice(), "insertion preserves A's saved source"); - for (file, original) in [(&picked, &original_a), (&other, &original_b), (&insert, &original_c)] { - assert_eq!(std::fs::read(&file.0).expect("read unchanged original picker fixture"), *original); + } +} + +fn assert_a_saved_source(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, original: &[u8]) { + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A retains exactly one insertion or cancellation"); + if let Some(doc) = doc { + assert_eq!(doc.saved_bytes().as_slice(), original, "insertion preserves A's saved source"); + } +} + +fn assert_picker_files_unchanged(files: &PickerFiles<'_>) { + for (file, original) in [(files.picked, files.original_a), (files.other, files.original_b), (files.insert, files.original_c)] { + let bytes = std::fs::read(&file.0); + assert!(bytes.is_ok(), "read unchanged original picker fixture: {bytes:?}"); + if let Ok(bytes) = bytes { + assert_eq!(bytes, original); } } } diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 62d97d4e58..0d8bc72de4 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -97,23 +97,32 @@ fn ctrl_alt_q_types_an_email_without_quitting() { fn drawing_a_signature_creating_an_id_signing_and_trusting() { let dir = dir(); let mut h = harness(dir.clone()); + create_signing_id(&mut h); + sign_with_created_id(&mut h, &dir); + trust_signer_and_inspect_certificate(&mut h); + verify_signed_snapshot_reuse(&mut h); + verify_dirty_snapshot_close(&mut h); + assert_trust_and_id_persist(&h); +} + +fn create_signing_id(h: &mut Harness<'static, PdfCraftApp>) { assert!(h.state_mut().execute("sign.digital")); assert_eq!(h.state().quick_tool, QuickTool::SignArea { certify: false }); - let (a, b) = (at(&h, 40.0, 90.0), at(&h, 220.0, 40.0)); - drag(&mut h, a, b); + let (a, b) = (at(h, 40.0, 90.0), at(h, 220.0, 40.0)); + drag(h, a, b); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure), "no IDs yet: configure one"); // Create a new digital ID (P-256 keeps the test fast). h.get_by_label("Configure a Digital ID for Signing"); - field(&h, "Name").click(); + field(h, "Name").click(); h.run_steps(1); - field(&h, "Name").type_text("Grace Hopper"); + field(h, "Name").type_text("Grace Hopper"); h.state_mut().sign_draft.as_mut().unwrap().new_id.key = 3; for (label, text) in [("Password", "secret12"), ("Confirm Password", "secret12")] { - field(&h, label).click(); + field(h, label).click(); h.run_steps(1); - field(&h, label).type_text(text); + field(h, label).type_text(text); h.run_steps(1); } h.get_by_label("Save").click(); @@ -124,31 +133,41 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Continue").click(); h.run_steps(2); h.get_by_label("Sign as \"Grace Hopper\""); +} +fn sign_with_created_id(h: &mut Harness<'static, PdfCraftApp>, dir: &std::path::Path) { // A wrong password says so. - field(&h, "Digital ID password").click(); + field(h, "Digital ID password").click(); h.run_steps(1); - field(&h, "Digital ID password").type_text("nope"); - support::outside_menus(&h, "Sign").click(); + field(h, "Digital ID password").type_text("nope"); + support::outside_menus(h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); - field(&h, "Reason").click(); + field(h, "Reason").click(); h.run_steps(1); - field(&h, "Reason").type_text("I approve"); - support::outside_menus(&h, "Sign").click(); + field(h, "Reason").type_text("I approve"); + support::outside_menus(h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.dialog, None); assert!(dir.join("contract_signed.pdf").exists()); - let doc = s.session.get(s.views[0].id).unwrap(); - assert_eq!(doc.name, "contract_signed.pdf"); - let sig = doc.signatures.iter().find(|x| x.signed).expect("signed"); - assert_eq!((sig.status, sig.reason.as_deref(), sig.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.name, "contract_signed.pdf"); + let signature = doc.signatures.iter().find(|signature| signature.signed); + assert!(signature.is_some(), "the saved document contains a signature"); + if let Some(signature) = signature { + assert_eq!((signature.status, signature.reason.as_deref(), signature.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + } + } assert_eq!(s.right, Some(RightPanel::Signatures)); h.get_by_label("At least one signature has problems."); +} +fn trust_signer_and_inspect_certificate(h: &mut Harness<'static, PdfCraftApp>) { // Signatures panel: expand, trust the signer → valid. h.get_by_label_contains("Signed by Grace Hopper").click(); h.run_steps(2); @@ -160,9 +179,13 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.run_steps(3); h.get_by_label("Signed and all signatures are valid."); let s = h.state(); - assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "the original signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(!doc.dirty, "trust alone is not an edit"); + } assert!(s.views[0].find.as_ref().is_some_and(|f| f.matches.is_empty()), "trust invalidates the view without zooming"); - assert!(!s.session.get(s.views[0].id).unwrap().dirty, "trust alone is not an edit"); // Show certificate: the Certificate Viewer. h.get_by_label("Show certificate…").click(); h.run_steps(3); @@ -181,13 +204,19 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("OK").click(); h.run_steps(2); assert_eq!(h.state().dialog, None); +} + +fn verify_signed_snapshot_reuse(h: &mut Harness<'static, PdfCraftApp>) { // View signed version opens the bytes the signature covers as a new document. h.get_by_label("View signed version").click(); h.run_steps(3); let s = h.state(); assert_eq!(s.views.len(), 2); - let v = s.session.get(s.views[1].id).unwrap(); - assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed)); + let signed_version = s.session.get(s.views[1].id); + assert!(signed_version.is_some(), "the signed version opens in its own tab"); + if let Some(version) = signed_version { + assert!(version.name.contains("signed version") && version.signatures.iter().any(|signature| signature.signed)); + } assert!(s.views[0].snapshot.is_none()); assert!(s.views[1].snapshot.as_ref().is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::SignedVersion)); // A repeat click reuses the snapshot tab instead of opening another identical one. @@ -219,15 +248,20 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { let Some(up) = node.parent() else { break }; node = up; } - let close = close.expect("the notice bar holds its own Close"); + assert!(close.is_some(), "the notice bar holds its own Close"); // The bar's Close sits in the bar's row and the tab chrome's sits above it. Without // this the walk could reach the chrome's button, which closes the same tab, and the // test would pass with the bar's own button never pressed. - assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); - close.click(); - h.run_steps(2); - assert_eq!(h.state().views.len(), 1); - assert!(h.state().close_request.is_none()); + if let Some(close) = close { + assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); + close.click(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().close_request.is_none()); + } +} + +fn verify_dirty_snapshot_close(h: &mut Harness<'static, PdfCraftApp>) { // Closing a dirty snapshot asks about saving instead of closing at once. h.get_by_label("View signed version").click(); h.run_steps(3); @@ -240,6 +274,9 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Don't save").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); +} + +fn assert_trust_and_id_persist(h: &Harness<'static, PdfCraftApp>) { // Trusted certificates and the ID list persist. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); From 21cae663d155442b76b4c3f16c9028a7ea215104 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 076/120] Refactor ui tools for strict Clippy checks --- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/comments/geometry.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 516 ++++++++++-------- crates/ui-egui/src/tools/comments/menu.rs | 5 +- crates/ui-egui/src/tools/comments/paint.rs | 5 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../src/tools/edit_text_ui/background.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 338 +++++++----- .../ui-egui/src/tools/edit_text_ui/layout.rs | 2 +- crates/ui-egui/src/tools/fill_sign/scan.rs | 5 +- crates/ui-egui/src/tools/forms_ui.rs | 172 +++--- crates/ui-egui/src/tools/measure_ui.rs | 67 ++- crates/ui-egui/src/tools/prepare/draft.rs | 2 +- .../ui-egui/src/tools/prepare/properties.rs | 2 +- crates/ui-egui/src/tools/prepare/tabs.rs | 2 +- crates/ui-egui/src/tools/sign_ui/dialog.rs | 5 +- crates/ui-egui/src/tools/sign_ui/panel.rs | 2 +- 18 files changed, 634 insertions(+), 502 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 8c83c3ba15..fa2d7f9b1a 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -1,7 +1,10 @@ use super::geometry::{drawn_text_box_rect, page_rotation, text_box_rect}; use super::paint::{edited_in_view, new_comment}; use super::state::{ComposerKind, PageCx}; -use super::*; +use super::{ + Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2, + theme, +}; /// The floating composer card (new note, new text box, edit). Returns the edit to apply. pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 75d56c2258..051cf09dba 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -1,5 +1,5 @@ use super::state::PageCx; -use super::*; +use super::{Annotation, DocInfo, PageInfo, PageText, Pos2, QuickTool, Rect, pos2, vec2}; pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d8cc33400b..ef0341b716 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::*; +use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). @@ -31,247 +31,15 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.context_at = Some((cx.page, cx.to_user(p))); cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); } + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { - // The shape just drawn is selected with its handles showing: they resize it without a - // trip to the Select tool (#2338). Anywhere else the tool keeps drawing. QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), - QuickTool::Comment(tool) if tool.draws() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - let p = clamp_to(page_rect, p); - let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { - let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); - if last.distance(p) >= 1.5 { - points.push(u); - } - } else { - points.truncate(1); - points.push(u); - } - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if tool == CommentTool::Eraser { - view.pending_edit = erase(cx, &points); - } else if let Some(shape) = drawn_shape(tool, &points) { - let now = ui.input(|i| i.time); - cv.settle(now, page, tool, &points); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats - // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a - // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink - && resp.clicked() - && over_page - && let Some(p) = pointer - && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) - { - cv.selected = None; - let now = ui.input(|i| i.time); - cv.settle(now, cx.page, tool, &[cx.to_user(p)]); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - true - } - QuickTool::Comment(tool) if tool.clicks_points() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(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. - // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. - if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) - && !ui.ctx().egui_wants_keyboard_input() - && ui.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) - }) - { - cv.undo_point(); - return true; - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let u = cx.to_user(p); - match cv.gesture.as_mut() { - Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { - // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); - if closes { - let pts = std::mem::take(points); - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &pts) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - return true; - } - if !repeat { - points.push(u); - } - } - _ => { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; - } - } - } - if finish - && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() - && page == cx.page - && t == tool - { - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &points) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - true - } - QuickTool::Comment(CommentTool::Callout) => { - // Drag from what the callout points at to where its text box goes. - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let [point, at] = points[..] - && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 - { - cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); - } - } - true - } - QuickTool::Comment(CommentTool::Attach) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; - } - true - } - QuickTool::Comment(CommentTool::Caret) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let (vx, vy) = cx.xf.screen_to_view(p); - let snapped = cx.info.pages.get(cx.page).zip(caret_text.as_deref()).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); - let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); - cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); - cv.selected = None; - } - true - } - QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(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). - if tool == CommentTool::TextBox { - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let (Some(first), Some(last)) = (points.first(), points.last()) { - let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); - // A drag of a few pixels is a click: the box fits its text. - let drawn = - (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); - let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); - cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); - } - } - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; - cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); - cv.selected = None; - } - true - } + QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), + QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), + QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), + QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), + QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), + QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), _ => false, @@ -512,6 +280,274 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } } +/// What the pointer is doing on one page, for the comment tools that draw or place things. +#[derive(Clone, Copy)] +struct Gestures<'a> { + ui: &'a egui::Ui, + resp: &'a egui::Response, + cx: &'a PageCx<'a>, + pointer: Option, + origin: Option, + page_rect: Rect, + pressed_here: bool, + over_page: bool, +} + +impl Gestures<'_> { + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + let p = clamp_to(page_rect, p); + let u = cx.to_user(p); + if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); + if last.distance(p) >= 1.5 { + points.push(u); + } + } else { + points.truncate(1); + points.push(u); + } + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if tool == CommentTool::Eraser { + view.pending_edit = erase(cx, &points); + } else if let Some(shape) = drawn_shape(tool, &points) { + let now = ui.input(|i| i.time); + cv.settle(now, page, tool, &points); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats + // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a + // single PDF point is visible and remains editable like any other drawing. + if tool == CommentTool::Ink + && resp.clicked() + && over_page + && let Some(p) = pointer + && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) + { + cv.selected = None; + let now = ui.input(|i| i.time); + cv.settle(now, cx.page, tool, &[cx.to_user(p)]); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + true + } + + fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(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. + // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. + if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) + && !ui.ctx().egui_wants_keyboard_input() + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) + { + cv.undo_point(); + return true; + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let u = cx.to_user(p); + match cv.gesture.as_mut() { + Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { + // Clicking the first point again closes a polygon. + let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + if closes { + let pts = std::mem::take(points); + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &pts) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + return true; + } + if !repeat { + points.push(u); + } + } + _ => { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); + cv.selected = None; + } + } + } + if finish + && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() + && page == cx.page + && t == tool + { + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &points) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + true + } + + fn callout(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + // Drag from what the callout points at to where its text box goes. + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let [point, at] = points[..] + && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 + { + cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); + } + } + true + } + + fn attach(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + cv.attach_at = Some((cx.page, cx.to_user(p))); + cv.selected = None; + } + true + } + + fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let (vx, vy) = cx.xf.screen_to_view(p); + let snapped = cx.info.pages.get(cx.page).zip(caret_text).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); + let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); + cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); + cv.selected = None; + } + true + } + + fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(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). + if tool == CommentTool::TextBox { + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let (Some(first), Some(last)) = (points.first(), points.last()) { + let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); + // A drag of a few pixels is a click: the box fits its text. + let drawn = + (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); + let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); + cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); + } + } + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; + cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); + cv.selected = None; + } + true + } +} + /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { if !ctx.input(|i| i.pointer.any_down()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 3d9a793111..d3de66a4e7 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,6 +1,9 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind}; -use super::*; +use super::{ + Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, + icons, theme, tool_for, vec2, +}; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 55e99d79ac..bfcd67982b 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,6 +1,9 @@ use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; use super::state::{Gesture, PageCx, SETTLE_SECS}; -use super::*; +use super::{ + Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, + Stroke, color32, vec2, +}; /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 97ccb365a2..36149bb2a0 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -1,5 +1,5 @@ use super::geometry::{corner_ratio, is_markup, resized}; -use super::*; +use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect}; /// The user's login name, which Acrobat uses as the default comment author. pub(super) fn login_name() -> String { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 66daa6edb8..297c3d9a31 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -20,7 +20,7 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, ImageSelection}; -pub(crate) use image::{ImageAction, image_input, image_owns_pointer}; +pub(crate) use image::{ImageAction, ImageCx, image_input, image_owns_pointer}; #[cfg(test)] use image::{rotate_knob, turn_degrees}; #[cfg(test)] diff --git a/crates/ui-egui/src/tools/edit_text_ui/background.rs b/crates/ui-egui/src/tools/edit_text_ui/background.rs index 71b2bffd31..591cfbf2a2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/background.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Color32, Edit, LineEditor, PageXform, Pos2, TextTarget}; /// A cached, read-only page background. The source glyphs are removed before rasterization; /// colour, pictures and unselected text beneath the editor remain visible. diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 9f55a7d5ed..2c231c19d8 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box}; /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] @@ -46,6 +46,18 @@ impl CropMode { } } +/// What the pointer and the page are, for the image tools on one page. +#[derive(Clone, Copy)] +pub(crate) struct ImageCx<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub info: &'a DocInfo, + pub allow_interaction: bool, +} + /// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left /// edge midpoints. fn crop_handles(k: Rect) -> [Pos2; 8] { @@ -54,19 +66,8 @@ fn crop_handles(k: Rect) -> [Pos2; 8] { /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` /// (the pointer and keys are the crop's while it lasts). -#[allow(clippy::too_many_arguments)] -fn crop_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - view: &mut DocView, - i: usize, - b: Rect, - allow_interaction: bool, -) -> bool { +fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -276,44 +277,26 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { if snap { (d / 15.0).round() * 15.0 } else { d } } -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. -#[allow(clippy::too_many_arguments)] -pub(crate) fn image_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, - allow_interaction: bool, -) -> bool { - let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); - let painter = ui.painter(); - let hover_pos = ptr.pos; - let image_contains = |index: usize, point: Pos2| { - let (x, y) = xf.screen_to_view(point); - let Some(p) = info.pages.get(page) else { return false }; - let user = p.view_to_user(x, y); - images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) - }; - let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); - // Few images: faint outlines for all of them, as before. Many: only the selected and - // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. - // Fully clipped placements (empty visible frames) get no outline. - let faint_all = boxes.len() <= MAX_OUTLINES; - // The picture being dragged has its frame at the pointer; its outline where it was would - // trail behind until the release. - let moving = selected.filter(|_| dragging); +/// Which boxes are outlined on the page, and why. +struct OutlineState { + selected: Option, + hovered_box: Option, + /// The picture being dragged, whose outline would trail behind until the release. + moving: Option, + dragging: bool, + /// Few images: faint outlines for all of them. + faint_all: bool, +} + +/// Outlines the images: all of them faintly when there are few, else the selected and hovered +/// ones; the hovered or selected one is emphasised. +fn draw_outlines(painter: &egui::Painter, boxes: &[Rect], state: OutlineState) { + let OutlineState { selected, hovered_box, moving, dragging, faint_all } = state; if faint_all { for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } - let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); if !faint_all { for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { @@ -332,24 +315,169 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } +} + +fn draw_handles(painter: &egui::Painter, rect: Rect) { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } +} + +/// Turning the picture by its rotate handle: the turned outline as a preview (the engine redraws +/// the pixels on release). +fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: Pos2) { + let ImageCx { ui, resp, ptr, page, .. } = *cx; + let painter = ui.painter(); + let hover_pos = ptr.pos; + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + // The turned outline as a preview; the engine redraws the pixels on release. + let centre = b.center(); + let (sin, cos) = degrees.to_radians().sin_cos(); + let turn = |c: Pos2| { + let v = c - centre; + centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) + }; + let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); + painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); + let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); + 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); + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + s.turning = false; + } + if degrees.abs() >= 0.05 { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); + } + } +} + +/// Where the screen may still show the picture from before an applied move, and whether the +/// page's whole raster is the old one. +#[derive(Clone, Copy)] +struct Blanked { + left_at: Option, + raster_old: bool, +} + +/// A frame of the picture being moved or resized: the stretched picture, its frame and handles, +/// and on release the edit. +fn drag_preview( + cx: &ImageCx<'_>, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + (i, b): (usize, Rect), + (start, opposite, p): (Pos2, Option, Pos2), + blanked: Blanked, +) { + let ImageCx { ui, resp, xf, ptr, page, info, .. } = *cx; + let painter = ui.painter(); + let Blanked { left_at, raster_old } = blanked; + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + let preview = match opposite { + // Resize from the opposite corner, keeping the aspect ratio. + Some(fixed) => { + let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + None => b.translate(p - start), + }; + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + // Its pixels: where the whole-page raster has it. + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, preview); + } + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(painter, preview); + } + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); + } + if preview != b { + view.pending_edit = + 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, + }); +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); + let painter = ui.painter(); + let hover_pos = ptr.pos; + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. + let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); + draw_outlines(painter, &boxes, OutlineState { selected, hovered_box, moving, dragging, faint_all }); // The selected image: frame, corner handles, dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); + return crop_input(cx, view, i, b); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); - } - }; // Until the page is rendered again its raster has an applied move or resize's picture // where it was: it stays blanked there and drawn where it went. // The whole-page raster and its tiles are replaced separately: the old frame stays blanked @@ -372,7 +500,7 @@ pub(crate) fn image_input( } if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); + draw_handles(painter, 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 }); @@ -408,99 +536,13 @@ pub(crate) fn image_input( } } if view.image_selection.as_ref().is_some_and(|s| s.turning) { - let shift = ui.input(|i| i.modifiers.shift); - let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); - // The turned outline as a preview; the engine redraws the pixels on release. - let centre = b.center(); - let (sin, cos) = degrees.to_radians().sin_cos(); - let turn = |c: Pos2| { - let v = c - centre; - centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) - }; - let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); - painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); - let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); - 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); - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - s.turning = false; - } - if degrees.abs() >= 0.05 { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); - } - } + turn_preview(cx, view, i, b, knob); return true; } if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) - } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - // The pixels follow the frame: the page raster's picture of the image, stretched - // into the preview over a blank where it was. Re-rendering every frame of a drag - // would lag; the edit renders it properly on release. - if preview != b - && let Some(tex) = view.page_raster(page) - { - // Its pixels: where the whole-page raster has it. - let source = left_at.filter(|_| raster_old).map_or(b, screen); - let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); - stretched_picture(painter, xf, tex, &blank, source, preview); - } - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - let norm = |r: Rect| { - let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); - Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) - }; - s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); - } - if preview != b { - view.pending_edit = - 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, - }); + drag_preview(cx, images, view, (i, b), (start, opposite, p), Blanked { left_at, raster_old }); return true; } if allow_interaction diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index a59c10899c..533cfa7314 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Color32, FontFamily, FontId, LineEditor, Rect}; /// The look shown for a paragraph: the family and weight guessed from its PDF font name. fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText { diff --git a/crates/ui-egui/src/tools/fill_sign/scan.rs b/crates/ui-egui/src/tools/fill_sign/scan.rs index 2b247f53a8..6179a9ab81 100644 --- a/crates/ui-egui/src/tools/fill_sign/scan.rs +++ b/crates/ui-egui/src/tools/fill_sign/scan.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, PhotoRect, Pos2, SCAN_MAX_SIDE, SavedSig, ScanError, Sense, SigDraft, + SignatureImage, SignaturePhoto, Stroke, Tokens, Turn, image_preview, pos2, vec2, +}; /// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 28494160c5..5a5ba3e1a5 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -623,6 +623,100 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() } +/// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the +/// caret where the press that opened the editor landed, and takes focus when asked. +fn settle_text_editor( + ui: &mut egui::Ui, + fx: &mut Focus, + r: &egui::Response, + laid_out: Option<(std::sync::Arc, Pos2)>, + fitted: Option<(&form_appearance::TextBox, String)>, +) { + if let Some((tb, before)) = fitted + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); + st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } + } + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } +} + +/// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab +/// order, returning the edit to apply (the commit's, or `edit`). +fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { + commit(view, form); + let pending = view.pending_edit.take(); + // The next field that can be filled in, check boxes and radio buttons included (#2223), in + // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. + let mut order: Vec<(usize, &FormField, usize)> = form + .iter() + .filter(|x| fillable(x)) + .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) + .collect(); + order.sort_by_key(|o| o.0); + if let Some(pos) = + order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) + { + let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; + let (_, target, wi) = order[k]; + view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); + if let Some(p) = target.widgets[wi].page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + edit.or(pending) +} + +/// The arrows move to the group's next or previous button and select it, wrapping. +fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) -> Option { + let mut edit = None; + let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); + if let Some(pos) = stops.iter().position(|&i| i == widget) { + let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; + if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { + view.forms.focus = Some(open_focus(f, wi)); + edit = select_edit(f, target); + if let Some(p) = target.page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + } + edit +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. /// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor /// then takes no more than fits. @@ -714,42 +808,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - if let (Some(tb), Some(before)) = (text_box, before) - && fx.text != before - { - let (kept, caret) = tb.fitted_edit(&before, &fx.text); - if kept != fx.text { - fx.text = kept; - if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); - st.store(ui.ctx(), r.id); - } - ui.ctx().request_repaint(); - } - } - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); - } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); - st.store(ui.ctx(), r.id); - } - } + settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -890,20 +949,7 @@ pub(crate) fn overlay( // turns the selected button off. edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) }; } else if let Some(forward) = step { - // The arrows move to the group's next or previous button and select it, wrapping. - let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); - if let Some(pos) = stops.iter().position(|&i| i == focus.widget) { - let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; - if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { - view.forms.focus = Some(open_focus(f, wi)); - edit = select_edit(f, target); - if let Some(p) = target.page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - } + edit = step_radio(view, f, focus.widget, forward); } } _ => close = true, @@ -912,31 +958,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - commit(view, form); - let pending = view.pending_edit.take(); - // The next field that can be filled in, check boxes and radio buttons included (#2223), in - // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = order - .iter() - .position(|(_, x, wi)| x.name == f.name && *wi == focus.widget) - .or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; - view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - edit = edit.or(pending); + edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 2e9e600dfc..c72e039b1b 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -166,6 +166,42 @@ impl MeasureView { self.scale = None; } } +/// Snaps `point` to the page's drawn geometry (endpoints, midpoints, intersections, paths), +/// recording in `state` why it couldn't when the content is too complex or unreadable. +fn snap_to_content( + state: &mut MeasureView, + doc: &Document, + page: usize, + view_xy: [f32; 2], + pixels_per_unit: f32, + point: Point, +) -> Option<(Point, measure::snap::SnapKind)> { + if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { + state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); + } + if let Some((_, _, Err(e))) = &state.paths { + state.error = Some(e.clone()); + } + if let Some((_, _, Ok(paths))) = &state.paths { + if paths.truncated { + state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); + } else if paths.unreadable > 0 { + state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); + } + let tolerance = doc + .measurement_to_user(page, [f64::from(view_xy[0]) + state.sensitivity / f64::from(pixels_per_unit), f64::from(view_xy[1])]) + .map(|p| measure::distance(point, p)) + .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); + if state.snaps.intersections && paths.intersection_limited(point, tolerance) { + state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); + } + if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { + return Some((hit.point, hit.kind)); + } + } + None +} + pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, @@ -204,32 +240,13 @@ pub(crate) fn page_input( 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)]; + let mut snapped = None; if state.snap_enabled { - if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { - state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); - } - let pixels_per_unit = xf.zoom().max(1e-6); - if let Some((_, _, Err(e))) = &state.paths { - state.error = Some(e.clone()); - } - if let Some((_, _, Ok(paths))) = &state.paths { - if paths.truncated { - state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); - } else if paths.unreadable > 0 { - state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); - } - let tolerance = doc - .measurement_to_user(page, [f64::from(vx) + state.sensitivity / f64::from(pixels_per_unit), f64::from(vy)]) - .map(|p| measure::distance(point, p)) - .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); - if state.snaps.intersections && paths.intersection_limited(point, tolerance) { - state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); - } - if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { - point = hit.point; - snap = Some(hit.kind); - } - } + snapped = snap_to_content(state, doc, page, [vx, vy], xf.zoom().max(1e-6), point); + } + if let Some((snapped_point, kind)) = snapped { + point = snapped_point; + snap = Some(kind); } if ui.input(|i| i.modifiers.shift) && let Some(last) = state.points.last() diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 5bba8fe185..c6f9bf4451 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Calculate, FieldLook, FieldProps, FormField, FormFieldKind, Format, Validate}; // ───────────────────────────────────────────────────────────────────────── Field Properties diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index 8099d57489..f922143f6d 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -1,6 +1,6 @@ use super::draft::{FieldDraft, FieldTab}; use super::tabs::{actions_tab, calculate_tab, format_tab, validate_tab}; -use super::*; +use super::{BorderStyle, FieldFont, FormFieldKind, theme, widgets}; /// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index b74e67dd19..04831353bd 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -1,5 +1,5 @@ use super::draft::{ACTION_KINDS, ActionDraft, FieldDraft}; -use super::*; +use super::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value, theme, widgets}; /// The categories of the Format tab. #[derive(Clone, Copy, PartialEq)] diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index ad5ef4b01d..feb4fdd207 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, + SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, +}; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { diff --git a/crates/ui-egui/src/tools/sign_ui/panel.rs b/crates/ui-egui/src/tools/sign_ui/panel.rs index af5bfd77cf..415c035f00 100644 --- a/crates/ui-egui/src/tools/sign_ui/panel.rs +++ b/crates/ui-egui/src/tools/sign_ui/panel.rs @@ -1,5 +1,5 @@ use super::dialog::title; -use super::*; +use super::{Align, Certificate, Color32, Layout, SignatureInfo, SignatureStatus, Tokens, icons, sign, theme, widgets}; /// The message bar for signed documents: (icon, colour, template, argument). /// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows From 2ca92751cfb87dc9126eb0112278c4f4225dcb21 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 077/120] Refactor ui workflows for strict Clippy checks --- .../src/workflows/combine_grid/card.rs | 217 +++++++++++------- .../src/workflows/combine_grid/thumbs.rs | 2 +- .../src/workflows/combine_ui/actions.rs | 149 +++++++----- .../ui-egui/src/workflows/combine_ui/files.rs | 2 +- .../ui-egui/src/workflows/combine_ui/parts.rs | 2 +- .../src/workflows/combine_ui/session.rs | 2 +- .../ui-egui/src/workflows/combine_ui/table.rs | 5 +- .../ui-egui/src/workflows/combine_ui/view.rs | 2 +- crates/ui-egui/src/workflows/print_ui/body.rs | 5 +- .../ui-egui/src/workflows/print_ui/draft.rs | 2 +- 10 files changed, 239 insertions(+), 149 deletions(-) diff --git a/crates/ui-egui/src/workflows/combine_grid/card.rs b/crates/ui-egui/src/workflows/combine_grid/card.rs index 63cf14c5ee..3e98daa545 100644 --- a/crates/ui-egui/src/workflows/combine_grid/card.rs +++ b/crates/ui-egui/src/workflows/combine_grid/card.rs @@ -1,5 +1,8 @@ use super::thumbs::{Key, Look, MAX_SIDE, PREVIEW, PREVIEW_SIDE, SIZE_STEP, Thumbs, Want}; -use super::*; +use super::{ + ABOVE, Align2, Arc, BAR_H, Color32, CombineFile, CornerRadius, ERROR, FALLBACK_PAGE, GridDrag, Lock, MAX_EXPAND, ON_PAPER, PdfCraftApp, Rect, + RowAction, Sense, Stroke, TextureHandle, Tokens, Vec2, WARNING, fit_name, icons, paper_size, pos2, theme, vec2, +}; /// One card's place and what it shows. pub(super) struct Card { @@ -18,6 +21,16 @@ pub(super) struct Card { pub(super) fit: Vec2, } +/// The paper box a card's page is drawn in. +struct Paper { + rect: Rect, + size: Vec2, + /// The page's size in points. + size_pt: (f32, f32), + /// Whether the page's own size is known (else a fallback is drawn and no preview is asked for). + known: bool, +} + impl Card { pub(super) fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { @@ -81,56 +94,7 @@ impl Card { painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); // What goes on the paper: the thumbnail, or why there isn't one. - let mut why: Option<(&str, Color32, String)> = None; - if let Some(lock) = f.lock { - let text = match lock { - Lock::Open => tl!("Password-protected"), - Lock::Permissions => tl!("Its security settings don't allow copying pages"), - }; - why = Some(("lock", ON_PAPER, text.to_owned())); - } else if let Some(p) = &f.problem { - why = Some(("circle-x", ERROR, p.clone())); - } else { - match page { - Err(e) => why = Some(("circle-x", ERROR, e)), - // The pages couldn't be counted, or the page's size is unknown (some passwords - // open a file for combining but not for reading it here): nothing is drawn (the - // engine decides at Combine), and the card says so rather than look blank. - Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), - Ok(p) => { - // Pixels per point: the size the paper is drawn at, on both sides, and never a - // side longer than a thumbnail's (a long, thin page fits by its long side). - let (w, h) = size_pt; - let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); - let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); - let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; - let look = thumbs.look((f.group, p), key); - let mut want = false; - match look { - Look::Ready(tex) => { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), - Look::Pending(stand_in) => { - if let Some(tex) = stand_in { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - want = true; - } - } - if want && scale.is_finite() && scale > 0.0 { - thumbs.wants.push(Want { - slot: (f.group, p), - key, - bytes: f.bytes.clone(), - password: f.password().map(Arc::from), - scale, - visible: ui.is_rect_visible(c), - }); - } - } - } - } + let why = self.paper_content(ui, &painter, f, thumbs, page, Paper { rect: paper, size, size_pt, known: known.is_some() }); if let Some((icon, colour, _)) = &why { icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); } @@ -187,26 +151,7 @@ impl Card { let previewable = why.is_none(); // A file that adds more pages than one can be spread out into them. let expandable = self.page.is_none() && takes > 1 && takes <= MAX_EXPAND && f.lock.is_none() && f.problem.is_none() && self.check.is_ok(); - // Shown large: the file from its first page, or a page card's page. - let preview = match self.page { - Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), - None => RowAction::Preview(f.id), - }; - // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, - // or the page), show large. - let mut buttons: Vec<(&str, String, RowAction)> = Vec::new(); - if expandable { - buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); - } - if let Some((nth, ..)) = self.page { - buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); - buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); - } else { - buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); - } - if previewable { - buttons.push(("search", tl!("Preview").to_owned(), preview)); - } + let buttons = self.card_buttons(f, expandable, previewable); // Nothing of the card shows over a dialog (the preview, the password box). let covered = ui.ctx().memory(|m| m.top_modal_layer().is_some()); let mut over_bar = false; @@ -227,24 +172,7 @@ impl Card { } } - // The full name and everything known about the file, on hover (on a page card, which page). - let mut tip = match &page_label { - Some(page) => format!("{}\n{page}", f.name), - None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), - }; - for line in why.iter().map(|(_, _, s)| s).chain(&f.notes) { - tip.push('\n'); - tip.push_str(line); - } - if self.twice { - tip.push('\n'); - tip.push_str(tl!("Added more than once")); - } - // Page ranges are typed in the list. - if self.page.is_none() && self.check.is_err() { - tip.push('\n'); - tip.push_str(tl!("Switch to List view to change the pages it takes.")); - } + let tip = self.tooltip(f, page_label.as_deref(), &pages, why.as_ref().map(|(_, _, reason)| reason.as_str())); // (Over the bar, its buttons say what they do instead.) let resp = if covered || over_bar { resp } else { resp.on_hover_text(tip) }; @@ -279,6 +207,117 @@ impl Card { } action } + + /// The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, or + /// the page), show large. + fn card_buttons(&self, f: &CombineFile, expandable: bool, previewable: bool) -> Vec<(&'static str, String, RowAction)> { + // Shown large: the file from its first page, or a page card's page. + let preview = match self.page { + Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), + None => RowAction::Preview(f.id), + }; + // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, + // or the page), show large. + let mut buttons: Vec<(&'static str, String, RowAction)> = Vec::new(); + if expandable { + buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); + } + if let Some((nth, ..)) = self.page { + buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); + } else { + buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); + } + if previewable { + buttons.push(("search", tl!("Preview").to_owned(), preview)); + } + buttons + } + + /// The full name and everything known about the file, on hover (on a page card, which page). + fn tooltip(&self, f: &CombineFile, page_label: Option<&str>, pages: &str, why: Option<&str>) -> String { + let mut tip = match page_label { + Some(page) => format!("{}\n{page}", f.name), + None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), + }; + for line in why.into_iter().chain(f.notes.iter().map(String::as_str)) { + tip.push('\n'); + tip.push_str(line); + } + if self.twice { + tip.push('\n'); + tip.push_str(tl!("Added more than once")); + } + // Page ranges are typed in the list. + if self.page.is_none() && self.check.is_err() { + tip.push('\n'); + tip.push_str(tl!("Switch to List view to change the pages it takes.")); + } + tip + } + + /// What goes on the paper: the thumbnail, or why there isn't one (an icon, its colour and the reason). + fn paper_content( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + f: &CombineFile, + thumbs: &mut Thumbs, + page: Result, + paper: Paper, + ) -> Option<(&'static str, Color32, String)> { + let mut why: Option<(&'static str, Color32, String)> = None; + if let Some(lock) = f.lock { + let text = match lock { + Lock::Open => tl!("Password-protected"), + Lock::Permissions => tl!("Its security settings don't allow copying pages"), + }; + why = Some(("lock", ON_PAPER, text.to_owned())); + } else if let Some(p) = &f.problem { + why = Some(("circle-x", ERROR, p.clone())); + } else { + match page { + Err(e) => why = Some(("circle-x", ERROR, e)), + // The pages couldn't be counted, or the page's size is unknown (some passwords + // open a file for combining but not for reading it here): nothing is drawn (the + // engine decides at Combine), and the card says so rather than look blank. + Ok(p) if p >= f.pages || !paper.known => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), + Ok(p) => { + // Pixels per point: the size the paper is drawn at, on both sides, and never a + // side longer than a thumbnail's (a long, thin page fits by its long side). + let (w, h) = paper.size_pt; + let scale = (paper.size.x * self.ppp / w).min(paper.size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; + let look = thumbs.look((f.group, p), key); + let mut want = false; + match look { + Look::Ready(tex) => { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), + Look::Pending(stand_in) => { + if let Some(tex) = stand_in { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + want = true; + } + } + if want && scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + slot: (f.group, p), + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: ui.is_rect_visible(self.rect), + }); + } + } + } + } + why + } } /// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a diff --git a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs index 5494cdc004..28c54b4bef 100644 --- a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs +++ b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, HashMap, RenderConfig, RenderPool, RenderRequest, Retiring, TextureHandle}; /// Render threads for thumbnails at once, counting those of retired pools until they exit. pub(super) const MAX_THREADS: usize = 3; diff --git a/crates/ui-egui/src/workflows/combine_ui/actions.rs b/crates/ui-egui/src/workflows/combine_ui/actions.rs index 1eee3f8d39..a4f80abdc8 100644 --- a/crates/ui-egui/src/workflows/combine_ui/actions.rs +++ b/crates/ui-egui/src/workflows/combine_ui/actions.rs @@ -1,7 +1,7 @@ use super::files::{Preview, RowAction}; use super::parts::{moved_pages, removed_pages, step_to}; use super::view::expandable; -use super::*; +use super::{BTreeSet, PdfCraftApp}; impl PdfCraftApp { pub(crate) fn combine_apply(&mut self, action: RowAction) { @@ -29,11 +29,38 @@ impl PdfCraftApp { a => a, }; // Page edits work on the list as parts. + let Some(action) = self.combine_page_action(action) else { return }; + // A file's card clicked (or the list's keys): the files are selected, no page. + if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { + self.combine_tab.selected_pages.clear(); + } match action { - RowAction::ClickPage(id, nth, m) => return self.combine_click_page(id, nth, m), + RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. } | RowAction::Unlock(..) => self.combine_select_action(action), + RowAction::MoveUp + | RowAction::MoveDown + | RowAction::Remove + | RowAction::RemoveFile(_) + | RowAction::Drop { .. } + | RowAction::DropAt { .. } + | RowAction::Sort(_) => { + self.combine_order_action(action); + } + // (Page edits were done above.) + RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} + _ => self.combine_preview_action(action), + } + } + + /// Page edits work on the list as parts. Returns the action back when it is not one. + fn combine_page_action(&mut self, action: RowAction) -> Option { + match action { + RowAction::ClickPage(id, nth, m) => { + self.combine_click_page(id, nth, m); + return None; + } RowAction::MovePages { page, revision, gap } => { if revision != self.combine_tab.revision { - return; + return None; } let moving = if self.combine_tab.selected_pages.contains(&page) { self.combine_tab.selected_pages.clone() } else { BTreeSet::from([page]) }; @@ -47,7 +74,7 @@ impl PdfCraftApp { if let Some(moved) = moved_pages(&parts, &moving, gap, &mut new_id) { self.combine_set_parts(moved); } - return; + return None; } RowAction::RemovePages(one) => { let removing = match one { @@ -55,7 +82,7 @@ impl PdfCraftApp { None => self.combine_tab.selected_pages.clone(), }; if removing.is_empty() { - return; + return None; } let parts = self.combine_parts(); let next = &mut self.combine_tab.next_id; @@ -68,14 +95,15 @@ impl PdfCraftApp { if let Some(left) = removed_pages(&parts, &removing, &mut new_id) { self.combine_set_parts(left); } - return; + return None; } _ => {} } - // A file's card clicked (or the list's keys): the files are selected, no page. - if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { - self.combine_tab.selected_pages.clear(); - } + Some(action) + } + + /// Selecting files, and asking for the passwords of locked ones. + fn combine_select_action(&mut self, action: RowAction) { let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); let n = ids.len(); let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); @@ -129,6 +157,31 @@ impl PdfCraftApp { tab.cursor = Some(id); tab.reveal = Some(id); } + RowAction::Unlock(row, at) => { + let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); + let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { + // A selected row brings the rest of the selection. + Some(id) if tab.selected.contains(&id) => { + ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() + } + Some(id) => { + tab.selected = BTreeSet::from([id]); + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + vec![id] + } + None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), + }; + self.combine_ask_password(rows, at); + } + _ => {} + } + } + + /// Moving, removing, dropping and sorting files. + fn combine_order_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::MoveUp | RowAction::MoveDown => { let up = matches!(action, RowAction::MoveUp); let selected: Vec = ids.iter().map(|id| tab.selected.contains(id)).collect(); @@ -181,6 +234,39 @@ impl PdfCraftApp { self.combine_tab.cursor = None; } } + RowAction::Drop { from, to } => { + let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; + // Dragging a selected file takes the whole selection along. + let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; + // Dropped on the selection itself: nothing to do. + if moving.contains(&target) { + return; + } + // Below the row when dragged down, above it when dragged up. + self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); + } + RowAction::DropAt { file, revision, gap } => { + // The list changed under the drag (e.g. undo): the gap no longer means the same. + if revision != tab.revision || !ids.contains(&file) { + return; + } + let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; + self.combine_move_to_gap(moving, gap); + } + RowAction::Sort(key) => { + let ascending = !matches!(tab.sort, Some((k, true)) if k == key); + self.combine_record(); + self.combine_sort(key, ascending); + } + _ => {} + } + } + + /// Previewing and expanding files. + fn combine_preview_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::Preview(id) => { if ids.contains(&id) { self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); @@ -228,48 +314,7 @@ impl PdfCraftApp { self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); } } - // (Page edits were done above.) - RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} - RowAction::Drop { from, to } => { - let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; - // Dragging a selected file takes the whole selection along. - let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; - // Dropped on the selection itself: nothing to do. - if moving.contains(&target) { - return; - } - // Below the row when dragged down, above it when dragged up. - self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); - } - RowAction::DropAt { file, revision, gap } => { - // The list changed under the drag (e.g. undo): the gap no longer means the same. - if revision != tab.revision || !ids.contains(&file) { - return; - } - let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; - self.combine_move_to_gap(moving, gap); - } - RowAction::Unlock(row, at) => { - let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); - let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { - // A selected row brings the rest of the selection. - Some(id) if tab.selected.contains(&id) => { - ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() - } - Some(id) => { - tab.selected = BTreeSet::from([id]); - (tab.anchor, tab.cursor) = (Some(id), Some(id)); - vec![id] - } - None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), - }; - self.combine_ask_password(rows, at); - } - RowAction::Sort(key) => { - let ascending = !matches!(tab.sort, Some((k, true)) if k == key); - self.combine_record(); - self.combine_sort(key, ascending); - } + _ => {} } } } diff --git a/crates/ui-egui/src/workflows/combine_ui/files.rs b/crates/ui-egui/src/workflows/combine_ui/files.rs index fdfa41deac..4947e4f8b1 100644 --- a/crates/ui-egui/src/workflows/combine_ui/files.rs +++ b/crates/ui-egui/src/workflows/combine_ui/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Modifiers, SourceProblem, SystemTime}; /// A column of the list: what it shows and what it sorts by. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index 74c6683825..7de66b074d 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeSet, MAX_FOLDER_DEPTH, MAX_FOLDER_FILES, Ordering, SystemTime}; /// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not /// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index c18b64e0eb..ad8d2a4029 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -2,7 +2,7 @@ use super::files::{AssessError, Assessed, CombineFile, Incoming, Secret, Snapsho #[cfg(not(target_arch = "wasm32"))] use super::parts::combinable_in; use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, title_of}; -use super::*; +use super::{Arc, BTreeSet, CombineTab, HISTORY, MAX_FOLDER_FILES, Modifiers, PdfCraftApp}; impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. diff --git a/crates/ui-egui/src/workflows/combine_ui/table.rs b/crates/ui-egui/src/workflows/combine_ui/table.rs index 3a95e87068..86015db39c 100644 --- a/crates/ui-egui/src/workflows/combine_ui/table.rs +++ b/crates/ui-egui/src/workflows/combine_ui/table.rs @@ -1,6 +1,9 @@ use super::files::{Columns, Lock, RowAction, SortKey}; use super::parts::{ago, utc_stamp}; -use super::*; +use super::{ + Align, Color32, Column, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Sense, Stroke, TableBuilder, Tokens, WARNING, icons, theme, + vec2, +}; /// A column heading being dragged to a new place. #[derive(Clone, Copy)] diff --git a/crates/ui-egui/src/workflows/combine_ui/view.rs b/crates/ui-egui/src/workflows/combine_ui/view.rs index 1e20332307..4e1d0c610f 100644 --- a/crates/ui-egui/src/workflows/combine_ui/view.rs +++ b/crates/ui-egui/src/workflows/combine_ui/view.rs @@ -1,6 +1,6 @@ use super::files::{CombineFile, CombineView, Lock, RowAction}; use super::table::{TableEvents, table}; -use super::*; +use super::{Align, BTreeSet, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The Combine files page. pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 5243820396..d5cdbf16ed 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,5 +1,8 @@ use super::draft::{Handling, PrintDraft, Which}; -use super::*; +use super::{ + Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, + Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, +}; /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. /// Only choices away from the printer's defaults are kept, and sent with the job. diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 1609262abb..be2a22efb3 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { From 9530dbb3e804992e4d57407dd60b4db08b03ff61 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 078/120] Refactor xfa for strict Clippy checks --- crates/xfa/src/data/build.rs | 2 +- crates/xfa/src/data/dates.rs | 2 +- crates/xfa/src/data/import.rs | 2 +- crates/xfa/src/data/merge.rs | 2 +- crates/xfa/src/data/passwords.rs | 2 +- crates/xfa/src/data/path.rs | 2 +- crates/xfa/src/data/values.rs | 2 +- crates/xfa/src/data/write.rs | 2 +- crates/xfa/src/layout/common.rs | 2 +- crates/xfa/src/layout/draw.rs | 5 +- crates/xfa/src/layout/field.rs | 5 +- crates/xfa/src/layout/flow.rs | 2 +- crates/xfa/src/layout/items.rs | 2 +- crates/xfa/src/layout/measure.rs | 5 +- crates/xfa/src/layout/pages.rs | 7 +- crates/xfa/src/pdf.rs | 128 +++++++++++++++++-------------- crates/xfa/src/pdf/common.rs | 2 +- crates/xfa/src/pdf/strip.rs | 2 +- crates/xfa/src/tests.rs | 16 ++++ 19 files changed, 118 insertions(+), 74 deletions(-) diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs index 68e8a2a364..98e34f0f49 100644 --- a/crates/xfa/src/data/build.rs +++ b/crates/xfa/src/data/build.rs @@ -1,5 +1,5 @@ use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{Document, HashMap, MAX_DEPTH, XFA_DATA_NS}; pub(super) fn xml_name(s: &str) -> String { let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 029d91b273..565c44117d 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -1,5 +1,5 @@ use super::path::template_field; -use super::*; +use super::{Field, Template}; /// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as /// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs index cec30b11b3..7582d86763 100644 --- a/crates/xfa/src/data/import.rs +++ b/crates/xfa/src/data/import.rs @@ -2,7 +2,7 @@ use super::build::{escape, fresh_datasets, xml_name}; use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; use super::values::Want; use super::write::{DatasetsWrite, rewrite}; -use super::*; +use super::{DataPath, Document, MAX_DEPTH, MAX_NODES, ObjRef, Object, XFA_DATA_NS, XfaError}; /// Data read from a file, held as a tree so it can be written into any packet with the /// prefix that packet uses, never as the file's own markup. diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 6888b1439a..48b4cedabf 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -2,7 +2,7 @@ use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, bui use super::import::{ImportNode, serialize_nodes}; use super::values::Want; use super::write::MAX_PACKET_WRITE; -use super::*; +use super::{DataPath, HashMap, MAX_NODES, XFA_DATA_NS}; /// What a rewrite of the datasets packet does. #[derive(Clone, Copy)] diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs index 78503bb53c..0f98a3f2b4 100644 --- a/crates/xfa/src/data/passwords.rs +++ b/crates/xfa/src/data/passwords.rs @@ -3,7 +3,7 @@ use super::merge::Op; use super::path::{data_path, parse_datasets}; use super::values::{Want, path_of, text_of, with_template}; use super::write::{datasets_stream_ref, rewrite}; -use super::*; +use super::{DataNode, DataPath, Document, MAX_DEPTH, Node, ObjRef, Object, SomIndices, Template, Ui, XfaError}; /// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, /// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 90cc4346f2..ef61cdbd59 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template}; fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { let name = n.tag_name().name().to_string(); diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 41fb6644e9..8042e774fe 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -1,6 +1,6 @@ use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; use super::path::{data_path, parse_datasets, som_to_path}; -use super::*; +use super::{DataPath, Dict, Document, ObjRef, Object, Template}; /// What a field holds, for writing the datasets. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs index 282dd33ef5..4667036fe5 100644 --- a/crates/xfa/src/data/write.rs +++ b/crates/xfa/src/data/write.rs @@ -2,7 +2,7 @@ use super::build::{MAX_INDEX, build_children, fresh_datasets}; use super::merge::{Op, Within, merge_all, readable}; use super::path::path_som; use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{DataOp, DataPath, Dict, Document, Encoding, HashMap, ObjRef, Object, PdfString, Stream, Template, XfaError, encode}; /// What [`write_datasets`] did. #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 3b60f8a6a0..625f305a6b 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,5 +1,5 @@ use super::items::{Action, Item, Rounded}; -use super::*; +use super::{HashMap, Layout, MAX_DEPTH, Node, Rect, Script, date_letters, iso_to_pattern}; pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { if depth > MAX_DEPTH { diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 4f318a73d6..f6520c1628 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,6 +1,9 @@ use super::common::clean_name; use super::items::{Item, Rounded}; -use super::*; +use super::{ + Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, + Subform, Value, XfaError, data_path, text, +}; impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index 40ec1497de..cc15f16788 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -1,6 +1,9 @@ use super::common::{button_action, date_pattern}; use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; -use super::*; +use super::{ + Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError, + data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, +}; impl Layouter<'_> { /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 48c57cd888..8f656d7eaf 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::*; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index 51383792c1..8a68388684 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Face, HAlign, Rect, Span}; /// How far below its baseline an underline is drawn, in the text's size: an underline is the /// level line pushed just before its span, this far below it (the PDF writer tells it apart from diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index cd004df982..2fffb3dfa0 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -1,6 +1,9 @@ use super::common::clamp_opt; use super::field::auto_check_reserve; -use super::*; +use super::{ + Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui, + Value, data_path, text, +}; impl Layouter<'_> { /// The width and height `node` takes in `avail_w`: its own size, else what its content needs diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 13047058b3..dac17c1169 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -1,5 +1,10 @@ +#[cfg(test)] +use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; -use super::*; +use super::{ + Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect, + SomIndices, XfaError, text, +}; impl Layouter<'_> { pub(super) fn warn_once(&mut self, msg: impl Into) { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index ab5d94bb45..0c21507abf 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -163,38 +163,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } - /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with - /// its border, background and kind, and return the annotation's object. - fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> (i64, bool, Option) { let r = w.rect; - let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"F".to_vec(), Object::Int(4)); - d.set(b"P".to_vec(), Object::Ref(page_ref)); - d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); - d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); - if let Some(t) = &w.tooltip { - d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); - } - d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); - let mut mk = Dict::new(); - if let Some(c) = w.border.color - && w.border.shape != BorderShape::None - { - mk.set(b"BC".to_vec(), rgb_array(c)); - } - if let Some(c) = w.border.fill { - mk.set(b"BG".to_vec(), rgb_array(c)); - } - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); - bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); - d.set(b"BS".to_vec(), Object::Dict(bs)); - if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { - d.set(ROUND_KEY.to_vec(), Object::Bool(true)); - } let mut ff: i64 = 0; if w.read_only { ff |= 1; @@ -383,34 +353,45 @@ impl Emitter<'_> { d.set(b"FT".to_vec(), Object::name("Btn")); ff |= 1 << 16; mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption))); - if let Some(Action::Script(_)) = &w.action { - // An XFA click script is no Acrobat JavaScript: no PDF action, which other - // viewers would run; PdfCraft finds the script by the field's SOM path. - d.set(CLICK_KEY.to_vec(), Object::Bool(true)); - } else if let Some(a) = &w.action { - let mut ad = Dict::new(); - match a { - Action::Reset => { - ad.set(b"S".to_vec(), Object::name("ResetForm")); - } - Action::Print => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("Print")); - } - Action::SaveAs => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("SaveAs")); - } - Action::Url(u) => { - ad.set(b"S".to_vec(), Object::name("URI")); - ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec()))); - } - Action::Script(_) => {} - } - d.set(b"A".to_vec(), Object::Dict(ad)); - } + write_button_action(w.action.as_ref(), d); } } + (ff, top_level, under) + } + + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. + fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + let r = w.rect; + let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"F".to_vec(), Object::Int(4)); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); + if let Some(t) = &w.tooltip { + d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); + } + d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); + let mut mk = Dict::new(); + if let Some(c) = w.border.color + && w.border.shape != BorderShape::None + { + mk.set(b"BC".to_vec(), rgb_array(c)); + } + if let Some(c) = w.border.fill { + mk.set(b"BG".to_vec(), rgb_array(c)); + } + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); + bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); + d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } + let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk); if !mk.is_empty() { d.set(b"MK".to_vec(), Object::Dict(mk)); } @@ -751,6 +732,39 @@ impl Emitter<'_> { } } +fn write_button_action(action: Option<&Action>, d: &mut Dict) { + match action { + Some(Action::Script(_)) => { + // An XFA click script is no Acrobat JavaScript: no PDF action, which other + // viewers would run; PdfCraft finds the script by the field's SOM path. + d.set(CLICK_KEY.to_vec(), Object::Bool(true)); + } + Some(action) => { + let mut ad = Dict::new(); + match action { + Action::Reset => { + ad.set(b"S".to_vec(), Object::name("ResetForm")); + } + Action::Print => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("Print")); + } + Action::SaveAs => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("SaveAs")); + } + Action::Url(url) => { + ad.set(b"S".to_vec(), Object::name("URI")); + ad.set(b"URI".to_vec(), Object::String(PdfString::literal(url.as_bytes().to_vec()))); + } + Action::Script(_) => {} + } + d.set(b"A".to_vec(), Object::Dict(ad)); + } + None => {} + } +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs index e1325b19ae..7cbb1ce7a1 100644 --- a/crates/xfa/src/pdf/common.rs +++ b/crates/xfa/src/pdf/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Family, Object, PdfString, Rect, Rounded, Stream}; /// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT /// markers). `None` for anything else. diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index de395e37e9..2f3084ec93 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CLICK_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; /// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). pub(crate) const USAGE_RIGHTS_REMOVED: &str = diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 39a1c93483..ca129abceb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1512,6 +1512,11 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { /// widget, and their AcroForm fields and image XObjects once written. #[test] fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { + assert_choice_widget_layout(); + assert_choice_fields_and_picture_streams(); +} + +fn assert_choice_widget_layout() { let form = layout_xml(&fields_template("")).unwrap(); let p1 = &form.pages[0]; let w = widgets(p1); @@ -1546,7 +1551,9 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}"); assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings); assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings); +} +fn assert_choice_fields_and_picture_streams() { // Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password // field, and Flate images (the PNG with a soft mask). let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); @@ -3556,6 +3563,11 @@ fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { + assert_stripping_leaves_ordinary_document(); + assert_stripping_handles_unbound_data_and_shared_field_tree(); +} + +fn assert_stripping_leaves_ordinary_document() { use crate::pdf::strip; let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); render_into(&mut doc).unwrap(); @@ -3606,6 +3618,10 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { let r = strip(&mut doc).unwrap(); assert_eq!(r, crate::Stripped::default()); assert!(!doc.is_modified()); +} + +fn assert_stripping_handles_unbound_data_and_shared_field_tree() { + use crate::pdf::strip; // Data no field holds is counted; a form whose click buttons ran scripts too; the shape // check needs no packet decoded and tells a never-laid-out dynamic form from one to strip. let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("", "
3kept nowherex
")))).unwrap(); From 92bc2f9acd38107f24516b9c3cdd8c214eb423d4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 079/120] Refactor xfdf for strict Clippy checks --- crates/xfdf/src/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfdf/src/common.rs b/crates/xfdf/src/common.rs index 5670415d38..c4ac9f600d 100644 --- a/crates/xfdf/src/common.rs +++ b/crates/xfdf/src/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataError, Dict, Document, Object}; // ── names ─────────────────────────────────────────────────────────────────────────────────── From d95ff3898d6ca8071fe322d2c427a00893d3173c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 080/120] Enforce workspace Clippy standards and document lint policy --- CLAUDE.md | 1 + Cargo.toml | 3 +++ clippy.toml | 1 + xtask/Cargo.toml | 3 +++ 4 files changed, 8 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56e..a37cec4ca6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. - **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. diff --git a/Cargo.toml b/Cargo.toml index 99f9fa251b..24313021bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/clippy.toml b/clippy.toml index e908d13581..31e54da30f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,6 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a33bad875..028282e073 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false license.workspace = true +[lints] +workspace = true + [dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-cos = { workspace = true } From 63bc58a3d57ed7a521d35c10cd7aa54c4889e36e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:16:56 +0000 Subject: [PATCH 081/120] Fix menu overflow and form pointer handling --- crates/ui-egui/src/canvas.rs | 10 +++++++--- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/tools/forms_ui.rs | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index adf22626b2..ee556ba4a3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -886,6 +886,7 @@ struct PageOut { open_initials: bool, open_signature: bool, open_table: Option<(usize, [f64; 4])>, + primary_down: bool, refused: Option, stamp_placed: bool, } @@ -1908,6 +1909,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: _, refused, stamp_placed, } = page_out; @@ -2081,6 +2083,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: ptr.primary_down, refused, stamp_placed, }; @@ -2542,7 +2545,7 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. -fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. @@ -2566,7 +2569,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hove view.pending_edit = Some(e); } let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { @@ -3077,6 +3080,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_initials, open_signature, open_table, + primary_down, mut refused, stamp_placed, } = page_out; @@ -3103,7 +3107,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let pan_held = track_scroll(ui, view, &out, glide); plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); - let text_done = frame_overlays(ui, view, &env, hover_text); + let text_done = frame_overlays(ui, view, &env, primary_down, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c8bef1cd..d5a33aea34 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -254,7 +254,7 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { - tl!(english).to_owned() + if english == "⋯" { tl!("More").to_owned() } else { tl!(english).to_owned() } } /// The layouts' own labels (About, Quit, Window…) through the app's translations. diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 5a5ba3e1a5..6bcec64697 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -629,6 +629,7 @@ fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, r: &egui::Response, + primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, ) { @@ -653,7 +654,7 @@ fn settle_text_editor( let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { + if primary_down { ui.ctx().set_dragged_id(r.id); } } @@ -725,11 +726,12 @@ pub(crate) fn overlay( view: &mut DocView, info: &DocInfo, form: &[FormField], + primary_down: bool, text_box: Option<&form_appearance::TextBox>, today: (i64, u32, u32), ) -> Option { let pressed = view.forms.pressed; - if !ctx.input(|i| i.pointer.primary_down()) { + if !primary_down { view.forms.pressed = false; } let toggle = std::mem::take(&mut view.forms.toggle); @@ -808,7 +810,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); + settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) From 81c63c037a70cb01dca676f6c9e0293b952699b2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:39 +0000 Subject: [PATCH 082/120] Group annotation, form and document operation parameters --- crates/annot/README.md | 2 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 30 ++++-------------- crates/annot/src/tests.rs | 59 ++++++++++++++++++++++++++--------- crates/annot/src/types.rs | 27 ++++++++++++++++ crates/engine/src/edit/run.rs | 22 +++++++++++-- crates/engine/src/open.rs | 28 +++++++++-------- crates/forms/src/actions.rs | 14 ++++++--- crates/measure/src/tests.rs | 2 +- crates/xfdf/src/tests.rs | 17 ++++++++-- 10 files changed, 140 insertions(+), 63 deletions(-) diff --git a/crates/annot/README.md b/crates/annot/README.md index 8108201835..999baec20a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 1d85d0bd64..e9e0359ce0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,7 +41,7 @@ pub use modify::{ }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, rect_quad, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 7872a6c817..fc81348075 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -6,6 +6,7 @@ use super::info::{line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, + StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; @@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 { } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; unlocked(doc, r)?; let d = annot_dict(doc, r); @@ -521,7 +513,7 @@ pub fn set_style( if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { return Err(AnnotError::Invalid("invalid style".into())); } - if let Some(ends) = endings { + if let Some(ends) = &endings { let callout = subtype == "FreeText" && d.contains(b"CL"); let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { @@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that /// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id /// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( doc, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb9..eb8d1a0f15 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 40ed744df3..bd3409b5ae 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -563,6 +563,33 @@ pub struct Style { pub fill: Option, } +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + impl Default for Style { fn default() -> Self { Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7bf5467fcf..49f19fa637 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -331,7 +331,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + pdfcraft_annot::add_text_replacement( + doc, + pdfcraft_annot::TextReplacement { + page: *page, + quads, + replacement: text, + author, + strike, + caret, + strike_meta: &strike_meta, + caret_meta: &caret_meta, + }, + )?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -341,7 +353,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + pdfcraft_annot::set_style( + doc, + *page, + *index, + pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() }, + &cx.meta(), + )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index d8950d1d6d..58bc8327e2 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -25,6 +25,17 @@ pub struct PreparedDocument { document: Document, } +struct DocumentOpenData<'a> { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + impl OpenContext { /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); // A script that ran away on open turns the form's scripts off for this document. document.xfa_template = if ran_away { None } else { xfa_template }; document.js_output.append(script_output); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1b..5ebe3269cc 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf0..5b277e5546 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2bb54f9232..52a4a5b86d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -151,7 +151,20 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; - add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &meta("strike"), + caret_meta: &meta("caret"), + }, + ) + .unwrap(); assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); let xfdf = export_xfdf(&src, true, false, "a.pdf"); From 5fecdb9cee0fa6d3c3bbe4a7a9f81a5fbeefdfcb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:40 +0000 Subject: [PATCH 083/120] Group signing and timestamp validation inputs --- crates/sign/src/cms.rs | 27 +++++++++------- crates/sign/src/pdf/validate.rs | 57 +++++++++++++++++---------------- crates/sign/src/timestamp.rs | 30 ++++++++++------- crates/sign/tests/it/pdf.rs | 37 ++++++++++++++------- 4 files changed, 89 insertions(+), 62 deletions(-) diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index b148ea4d19..4e232af616 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -376,7 +376,7 @@ pub fn sign_detached( alg: DigestAlg, content_digest: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, DATA, None, content_digest) + build(SignedDataInput { key, cert, chain, alg, content_type: DATA, content: None, content_digest }) } /// A CMS SignedData that encapsulates `content` under `content_type` (an RFC 3161 token wraps @@ -389,20 +389,23 @@ pub fn sign_encapsulated( content_type: &str, content: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, content_type, Some(content), &alg.digest(&[content])) + let content_digest = alg.digest(&[content]); + build(SignedDataInput { key, cert, chain, alg, content_type, content: Some(content), content_digest: &content_digest }) } -// The parameters mirror the SignedData fields being assembled. -#[allow(clippy::too_many_arguments)] -fn build( - key: &PrivateKey, - cert: &Certificate, - chain: &[Certificate], +/// The values needed to assemble a CMS SignedData object. +struct SignedDataInput<'a> { + key: &'a PrivateKey, + cert: &'a Certificate, + chain: &'a [Certificate], alg: DigestAlg, - content_type: &str, - content: Option<&[u8]>, - content_digest: &[u8], -) -> Result, SignError> { + content_type: &'a str, + content: Option<&'a [u8]>, + content_digest: &'a [u8], +} + +fn build(input: SignedDataInput<'_>) -> Result, SignError> { + let SignedDataInput { key, cert, chain, alg, content_type, content, content_digest } = input; let cert_hash = DigestAlg::Sha256.digest(&[&cert.raw]); let general_names = der::seq(&[&der::explicit(4, &cert.issuer.raw)]); let ess = der::seq(&[&der::seq(&[&der::seq(&[&der::octets(&cert_hash), &der::seq(&[&general_names, &der::uint(&cert.serial)])])])]); diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 668798c20b..08b482c4b4 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -5,6 +5,22 @@ use super::{ Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, }; +struct SignatureValidation<'a> { + doc: &'a Document, + bytes: &'a [u8], + trust: &'a TrustStore, + info: &'a mut SignatureInfo, + cache: &'a DigestCache, +} + +struct VerifiedSignature<'a> { + covered: usize, + certificate: Certificate, + embedded: &'a [Certificate], + cms_time: Option