From 6e3a16f96a25734c107745535980b588399ea45c Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:38:08 +0000 Subject: [PATCH 01/75] Issue #2368: flattening keeps a NoRotate widget upright on a rotated page A widget (or any annotation) with the NoRotate flag is shown upright on a page with /Rotate: its appearance turns with the page about the rectangle's upper-left corner (ISO 32000-2 12.5.3), which is how MuPDF and Poppler draw it. Flattening drew the appearance onto the rectangle as it lies in user space (Algorithm 8.1 alone), so on a /Rotate 90 page the flattened widget turned sideways and moved while its neighbours without the flag stayed put. The placement matrix of a NoRotate annotation is now followed by that turn (`upright`), for 90, 180 and 270. Regression test: tests::flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page (a plain and a NoRotate widget on pages rotated 0/90/180/270; the plain one's cm is unchanged, the NoRotate one's is the turned matrix). Verified on qpdf's manual-appearances.pdf page 2 (/Rotate 90): Poppler's render of the flattened page differs from the original's by 80 pixels at 36 dpi (anti-aliasing), against 648 before, when the NoRotate widget's box moved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/edit/src/flatten.rs | 25 +++++++++++++++++++++++++ crates/edit/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..dbc356656f 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::{EditError, check, n, page_list, place_tagged}; const HIDDEN: i64 = 2; +const NO_ROTATE: i64 = 16; const NO_VIEW: i64 = 32; fn nums(doc: &Document, o: Option<&Object>) -> Option> { @@ -52,6 +53,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy]) } +/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of +/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its +/// upper-left corner where it was, which is how MuPDF and Poppler draw it. +fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] { + let (sin, cos) = match degrees.rem_euclid(360) { + 90 => (1.0, 0.0), + 180 => (0.0, -1.0), + 270 => (-1.0, 0.0), + _ => return m, + }; + let (px, py) = (rect[0], rect[3]); + let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos]; + [ + m[0] * t[0] + m[1] * t[2], + m[0] * t[1] + m[1] * t[3], + m[2] * t[0] + m[3] * t[2], + m[2] * t[1] + m[3] * t[3], + m[4] * t[0] + m[5] * t[2] + t[4], + m[4] * t[1] + m[5] * t[3] + t[5], + ] +} + #[derive(Clone, Copy)] enum Which { /// Every comment and/or every form field. @@ -157,6 +180,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. let mut undrawn: HashSet = HashSet::new(); + let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -193,6 +217,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result> >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>" + ), + &widget(4), + &widget(4 | 16), + &page, + form, + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2); + let doc = reopen(&doc); + let s = streams(&doc, 0); + let flat = s.last().unwrap(); + assert!(flat.contains(plain), "/Rotate {rotate}: {flat}"); + assert!(flat.contains(upright), "/Rotate {rotate}: {flat}"); + } +} + /// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden /// cloudy square and a cloudy square that already has an appearance keep their previous results. #[test] From 644f7ab685d5e135dc57953e9c081c81ea5dfba6 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 18:51:55 +0800 Subject: [PATCH 02/75] fix(render): clip extracted text to the page CropBox (#2670) --- apps/pdfcraft-cli/tests/text.rs | 36 ++++++++++++++ crates/render/src/text.rs | 86 ++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/text.rs index 3d39a9e284..b7fb2511b7 100644 --- a/apps/pdfcraft-cli/tests/text.rs +++ b/apps/pdfcraft-cli/tests/text.rs @@ -243,4 +243,40 @@ mod page_argument_tests { assert_eq!(std::fs::read(&source).unwrap(), bytes); } } + + #[test] + fn text_clips_glyphs_outside_the_crop_box() { + let dir = PageDir::new(); + let path = dir.0.join("cropped.pdf"); + let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(&path, pdf).unwrap(); + + for args in [vec![], vec!["--page", "1"]] { + let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {out:?}"); + assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}"); + } + } } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b767e28314..5827a87f62 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -358,6 +358,8 @@ struct TextDevice { actual: Option<(usize, String, Vec)>, /// Every `/ActualText` replacement made on the page. replaced: Vec, + /// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`). + page: Rect, filter: TextFilter, excluded_depth: Option, actual_filtered: bool, @@ -431,6 +433,7 @@ impl TextDevice { depth: 0, actual: None, replaced: Vec::new(), + page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY), filter: TextFilter::default(), excluded_depth: None, actual_filtered: false, @@ -619,7 +622,12 @@ impl<'a> Device<'a> for TextDevice { let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y)) }); - if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 { + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) + || b.width() > 10_000.0 + || b.height() > 10_000.0 + || b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) + || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1) + { return; } let length = dx.hypot(dy); @@ -677,8 +685,10 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret // as in Acrobat, and match what other extractors report. let settings = settings.clone(); let initial = p.initial_transform(true).to_kurbo(); - let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); + let c = p.intersected_crop_box(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings); let mut dev = TextDevice::new(); + dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1)); dev.filter = filter; interpret_page(p, &mut ctx, &mut dev); // A replacement the content never closed ends with the page. @@ -2646,4 +2656,76 @@ mod tests { assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode"); assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only"); } + + #[test] + fn text_outside_the_crop_box_is_clipped() { + use lopdf::{Document, Object, Stream, dictionary}; + let ops = [ + "BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET", + "BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <> BDC (in) Tj EMC ET", + "BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET", + "BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET", + "BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET", + "BT /F1 12 Tf 1 0 0 1 100 370 Tm (AboveOut) Tj ET", + "BT /F1 12 Tf 1 0 0 1 100 20 Tm /Span <> BDC (out) Tj EMC ET", + "BT /F1 12 Tf 1 0 0 1 46 100 Tm (A) Tj ET", + ] + .join("\n"); + for rotate in [0, 90, 180, 270] { + for unit in [1, 2] { + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages, + "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], + "CropBox" => vec![Object::Integer(50), Object::Integer(60), Object::Integer(350), Object::Integer(340)], + "Rotate" => rotate, + "UserUnit" => unit, + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + "Contents" => content, + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "Inside crop\nActualIn\nA", "rotate={rotate} unit={unit}"); + assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}"); + assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}"); + for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut"] { + assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}"); + } + } + } + + // A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region, + // even when a glyph straddles the clamped intersection point. + for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] { + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages, + "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], + "CropBox" => crop.map(Object::Integer).to_vec(), + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + "Contents" => content, + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text()); + } + } } From cba63a26e412f33625420dc6580603f054b1f8f3 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 20:29:42 +0800 Subject: [PATCH 03/75] M3: wait for OS-open preparation before removing its fixture The open worker can still be reading after the harness's four startup frames. Removing the synthetic file then asserted zero tabs before opening completed. A fixture-specific 300 ms delay reproduced the exact ui.rs:843 failure on 644f7ab6; waiting by state passes under the same delay. Keep the file until its tab opens, retaining the one-tab, filename and OS-quit assertions. The diagnostic delay is removed. CropBox production code is unchanged. Narrowly adapted from Guillem Hermida's OS-event test repair in upstream https://github.com/storytold/pdfcraft/pull/2812 at 47e5398b6ba650dbc7008a23f5231b2767af41a0 (MIT OR Apache-2.0). No other changes from that PR are included. Validation: controlled red/green; final UI suite 52 passed, zero ignored; CropBox render regression passed with its fully qualified exact test name; strict UI Clippy all targets, formatting, layers, assets and wasm (31 crates). The CLI CropBox acceptance test will also be verified in automatic exact-head CI. Co-authored-by: Guillem Hermida --- ROADMAP.md | 2 ++ crates/ui-egui/tests/ui.rs | 3 +++ docs/roadmap.md | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..6ea1d13501 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-16 (M3, PR #2838 CI):** The OS-open regression keeps its synthetic file until asynchronous preparation opens its tab, then verifies the original one-tab, filename and quit behavior. A controlled worker delay reproduced the old zero-tab failure. CropBox behavior and estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c4..14ca669f26 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -839,6 +839,9 @@ fn files_and_quit_from_the_operating_system() { let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])])); let q = queue.clone(); let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + // Reading and preparation run on the open worker. Keep its source file until the tab + // is ready, regardless of how many UI frames the worker takes. + support::wait_until(&mut h, "the file the operating system opened", |app| !app.views.is_empty()); std::fs::remove_file(&path).ok(); assert_eq!(h.state().views.len(), 1, "the file opens in a tab"); h.get_by_label_contains(&name); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..8046c14774 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -101,7 +101,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors.. OS-open regression waits for asynchronous tab creation before removing its fixture (PR #2838 CI). | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | From c6bf3d0dbd96b5bca5817d4e49518458459b24c5 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 22:39:15 +0800 Subject: [PATCH 04/75] fix(render): avoid emitting full ActualText replacement when span is partially cropped (#2838) --- crates/render/src/text.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e9ad024775..d4066ec14c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -363,6 +363,7 @@ struct TextDevice { filter: TextFilter, excluded_depth: Option, actual_filtered: bool, + actual_cropped: bool, partial_replacement: bool, } @@ -437,6 +438,7 @@ impl TextDevice { filter: TextFilter::default(), excluded_depth: None, actual_filtered: false, + actual_cropped: false, partial_replacement: false, } } @@ -451,12 +453,17 @@ impl TextDevice { /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. fn end_actual_text(&mut self) { let Some((_, text, drawn)) = self.actual.take() else { return }; + let cropped = std::mem::take(&mut self.actual_cropped); if std::mem::take(&mut self.actual_filtered) { // ActualText has no mapping from replacement words to individual glyphs. A mixed // span cannot be filtered honestly; a wholly excluded span can simply be omitted. self.partial_replacement |= !drawn.is_empty(); return; } + if cropped { + self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); + return; + } let Some(glyph) = merge(&drawn, text.clone()) else { self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); return; @@ -622,12 +629,14 @@ impl<'a> Device<'a> for TextDevice { let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y)) }); + let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) + || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 - || b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) - || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1) + || outside_crop { + self.actual_cropped |= replaced && outside_crop; return; } let length = dx.hypot(dy); @@ -2812,6 +2821,7 @@ mod tests { let ops = [ "BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET", "BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <> BDC (in) Tj EMC ET", + "BT /F1 12 Tf 1 0 0 1 5 125 Tm /Span <> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET", "BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET", "BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET", "BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET", @@ -2843,10 +2853,11 @@ mod tests { doc.save_to(&mut bytes).unwrap(); let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); - assert_eq!(text.plain_text(), "Inside crop\nActualIn\nA", "rotate={rotate} unit={unit}"); + assert_eq!(text.plain_text(), "Inside crop\nActualIn\nKeptSpan\nA", "rotate={rotate} unit={unit}"); assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}"); assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}"); - for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut"] { + assert!(!text.find("KeptSpan").is_empty(), "rotate={rotate} unit={unit}"); + for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut", "CroppedSpan", "OutSpan"] { assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}"); } } From 87e68246c892c58ffbdfff0e2614610ccbcb1f2d Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 22:43:01 +0800 Subject: [PATCH 05/75] style(render): rustfmt CropBox condition (#2838) --- crates/render/src/text.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index d4066ec14c..08e50ae37a 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -629,8 +629,7 @@ impl<'a> Device<'a> for TextDevice { let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y)) }); - let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) - || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1); + let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 From d76516219d5df5a28efe24fbec3b9a8b7f66a221 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 23:09:40 +0800 Subject: [PATCH 06/75] M2: keep hidden ActualText fixture outside CropBox (#2838) --- crates/render/src/text.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 08e50ae37a..8299142d66 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -2820,7 +2820,8 @@ mod tests { let ops = [ "BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET", "BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <> BDC (in) Tj EMC ET", - "BT /F1 12 Tf 1 0 0 1 5 125 Tm /Span <> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET", + // Keep every glyph of OutSpan outside the crop; a boundary-crossing glyph remains visible. + "BT /F1 12 Tf 1 0 0 1 -100 125 Tm /Span <> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET", "BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET", "BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET", "BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET", From 4ce68f517b072ee96bf83319e2ccced1a5971b4d Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:03:08 +0200 Subject: [PATCH 07/75] M4: New bookmarks from text styles, for headings that aren't tagged (#1693) Bookmark options > New bookmarks from text styles... lists the styles the document's text is set in (font without its subset tag, the size shown, bold, italic, how many lines, a sample), the largest first, and takes one style per bookmark level. Every line in a level's style becomes a bookmark of that level under a new first "Untitled" bookmark, as from structure; a heading set on several rows is one bookmark, and each goes to the top of its heading. The detection lives in engine::text_style_bookmarks on top of edit::text_lines, reading each page top to bottom then left to right, so two headings on one row stay two. text_styles and bookmark_from_text_styles reach it headlessly, and the command bookmark.from_text_styles opens the dialog. OutlineEntry gets an optional top for the /XYZ destination; structure bookmarks keep theirs. --- crates/automation/src/lib.rs | 58 ++++++ crates/automation/src/tools.rs | 33 ++++ crates/automation/tests/it/automation.rs | 72 ++++++++ crates/engine/src/commands.rs | 2 + crates/engine/src/lib.rs | 27 ++- crates/engine/src/tests.rs | 83 +++++++++ crates/engine/src/text_style_bookmarks.rs | 211 ++++++++++++++++++++++ crates/organize/src/outline.rs | 17 +- crates/organize/src/tests.rs | 2 +- crates/ui-egui/src/bookmark_styles_ui.rs | 127 +++++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/editing.rs | 5 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels.rs | 6 + crates/ui-egui/tests/it/bookmarks.rs | 91 ++++++++++ parity/acrobat-features.toml | 12 ++ 17 files changed, 746 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/text_style_bookmarks.rs create mode 100644 crates/ui-egui/src/bookmark_styles_ui.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e94354021f..30caf10ddc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -421,6 +421,34 @@ impl Automation { out["next"] = listing["next"].clone(); out } + "text_styles" => { + // The samples are the document's text, as text_lines is. + let doc = self.doc_to_copy(&a)?; + let styles: Vec = doc + .text_styles() + .into_iter() + .map(|u| { + json!({ + "font": u.style.font, + "size": u.style.size, + "bold": u.style.bold, + "italic": u.style.italic, + "lines": u.lines, + "pages": u.pages, + "sample": u.sample, + }) + }) + .collect(); + json!({ "styles": styles }) + } + "bookmark_from_text_styles" => { + let levels = text_style_levels(&a)?; + let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } "doc_protect" => self.doc_protect(&a)?, "page_replace" => { let pages = self.pages(&a, "pages")?; @@ -2422,6 +2450,36 @@ fn parse_style_ranges(value: Option<&Value>) -> Result Result> { + let levels = + a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?; + if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS { + return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles"))); + } + levels + .iter() + .enumerate() + .map(|(i, v)| { + let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}")); + let font = + v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?; + let size = + v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?; + let flag = |key: &str| match v.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(&format!("{key} must be true or false"))), + }; + Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? }) + }) + .collect() +} + /// Typed access to validated arguments. struct Args<'a>(&'a Value); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1bfcc77984..7785f84bc2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -344,6 +344,39 @@ pub fn tools() -> Vec { "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", ) .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "text_styles", + "List text styles", + "The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.", + ) + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "bookmark_from_text_styles", + "New bookmarks from text styles", + "Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "levels": { + "type": "array", + "minItems": 1, + "maxItems": 9, + "items": { + "type": "object", + "properties": { + "font": { "type": "string", "minLength": 1 }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }, + "required": ["font", "size"] + } + } + }), + &["doc", "levels"], + )), t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( json!({ "doc": doc(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9049299702..ca7d6b19cd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -503,6 +503,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() { assert_eq!(result["status"], "passed"); } +/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18, +/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693). +fn styled_headings_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()), + format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the +/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why +/// when it can't. +#[test] +fn bookmarks_from_text_styles_through_tools() { + let dir = workdir("text-style-bookmarks"); + std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone(); + assert_eq!( + styles, + json!([ + { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" }, + { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" }, + { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" }, + ]) + ); + // The styles as text_styles gave them, the chapters first. + let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]); + let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels })); + let top = &r["bookmarks"][0]; + assert_eq!(top["title"], "Untitled"); + let titles: Vec<(&str, u64)> = + top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect(); + assert_eq!(titles, [("Introduction", 1), ("Results", 2)]); + assert_eq!(top["children"][0]["children"][0]["title"], "Background"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles"); + // What can't be done says why. + let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels })); + assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size"))); + assert!( + matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold")) + ); + let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }])); + assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}"); + let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect(); + assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_)))); +} + /// A one-page PDF whose form holds one text field named `name`. fn form_pdf(name: &str) -> Vec { let objs: Vec = vec![ diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4ebfecafc7..25bf76a944 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"), + // Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693). + c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"), c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"), c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aa71d82fef..12915bc3ce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod signature_scan; mod stamp_preview; pub mod text_fonts; mod text_preview; +pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; @@ -619,6 +620,12 @@ impl Document { self.generation } + /// The styles the document's text is set in, the largest first, for bookmarks from text + /// styles (#1693). + pub fn text_styles(&self) -> Vec { + self.editor.as_ref().map(|e| text_style_bookmarks::styles(&e.cos)).unwrap_or_default() + } + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). pub fn text_lines(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() @@ -1488,6 +1495,11 @@ pub enum Edit { /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, + /// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests + /// under it, …), under a new first "Untitled" bookmark (#1693; see [`text_style_bookmarks`]). + BookmarksFromTextStyles { + levels: Vec, + }, /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. NumberPages { from: usize, @@ -2015,6 +2027,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Colour", "Style", "New bookmarks from structure", + "New bookmarks from text styles", "Number pages", "Measure distance", "Measure perimeter", @@ -2162,6 +2175,7 @@ impl Edit { Edit::SetBookmarkColor { .. } => Label::of("Colour"), Edit::SetBookmarkStyle { .. } => Label::of("Style"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), Edit::NumberPages { .. } => Label::of("Number pages"), // What was measured is part of the wording too, for the same reason. Edit::AddMeasurement(m) => Label::of(match m.kind { @@ -2334,6 +2348,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure + | Edit::BookmarksFromTextStyles { .. } | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -2586,10 +2601,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None }) .collect(); pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; } + Edit::BookmarksFromTextStyles { levels } => { + if levels.is_empty() { + return Err(EditError::Invalid("choose a text style for at least one bookmark level".into())); + } + let entries = text_style_bookmarks::headings(doc, levels); + if entries.is_empty() { + return Err(EditError::Invalid("no text is set in the chosen styles".into())); + } + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, Edit::AddMeasurement(m) => { measure::add(doc, m, &cx.meta())?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dc76159d14..0458cac519 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1846,6 +1846,89 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Three pages whose headings are set apart only by how they look (#1693): chapters in +/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title +/// runs over two lines, its bold font is another subset of the same font, and one section is set +/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row. +fn styled_headings_fixture() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET"; + let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \ + BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \ + BT /FB 14 Tf 72 600 Td (Section C) Tj ET"; + // Two sections on one row, the right one drawn first: read from the left. + let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET"; + let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>"; + pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>")) + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>") + .stream("", page1) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .stream("", page2) + .obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>") + .stream("", page3) + .build() +} + +/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones: +/// chapters at level 1, sections nested under the chapter before them. A two-line title is one +/// bookmark; a subset of the same font and a size set by the text matrix count as the style. +/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused. +#[test] +fn bookmarks_from_text_styles_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let listed: Vec<(String, f64, bool, usize, usize)> = + styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect(); + assert_eq!( + listed, + [ + ("Helvetica-Bold".to_string(), 18.0, true, 3, 2), + ("Helvetica-Bold".to_string(), 14.0, true, 5, 3), + ("Helvetica".to_string(), 11.0, false, 3, 2), + ] + ); + assert_eq!(styles[0].sample, "Chapter One"); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap(); + let outline = s.get(id).unwrap().info.outline.clone(); + assert_eq!( + outline_titles(&outline), + ["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"] + ); + let first = &outline[0].children[0]; + match first.view { + pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => { + assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}") + } + ref v => panic!("{v:?}"), + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + // One level is enough; the sections are then left out. + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]); + s.undo(id).unwrap(); + // Nothing chosen, or a style no text is in: refused, nothing changes. + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err(); + assert!(err.to_string().contains("at least one bookmark level"), "{err}"); + let unused = pdfcraft_engine_style(§ion, 30.0); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err(); + assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} + +/// `style` at another size. +fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { + text_style_bookmarks::TextStyle { size, ..style.clone() } +} + #[test] fn number_pages_shows_in_the_viewer_and_undoes() { let (mut s, id) = session_with(5); diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs new file mode 100644 index 0000000000..937752fa4c --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,211 @@ +//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how +//! they look (chapter titles in one font at one size, sections in another), not tagged as +//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the +//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level. +//! +//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are +//! subsets of one font), the size the text is shown at, and whether it is bold and italic. +//! Lines are taken in reading order down each page: top to bottom, then left to right. + +use pdfcraft_cos::Document; +use pdfcraft_organize::OutlineEntry; + +/// How a line of text looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// The font's name, without a subset tag. + pub font: String, + /// The size the text is shown at, in points (rounded to a tenth). + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +impl TextStyle { + /// Whether `other` is this style. Sizes within a quarter point match, as the same heading + /// size is often written with rounding differences (12 and 11.96). + fn matches(&self, other: &TextStyle) -> bool { + self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE + } +} + +/// How far apart two sizes may be and still be one style. +const SIZE_TOLERANCE: f64 = 0.25; + +/// A style and how much text is set in it. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyleUse { + pub style: TextStyle, + /// Lines in this style. + pub lines: usize, + /// Pages they are on. + pub pages: usize, + /// The first line in it, cut to [`SAMPLE_CHARS`] characters. + pub sample: String, +} + +/// The most pages read: a longer document is listed and searched by its first pages. +pub const MAX_PAGES: usize = 5000; +/// The most bookmarks made at once. +pub const MAX_BOOKMARKS: usize = 10_000; +/// The longest bookmark title kept, in characters (a heading run on into a paragraph). +const MAX_TITLE_CHARS: usize = 300; +/// The longest sample shown for a style. +const SAMPLE_CHARS: usize = 60; +/// The most styles listed, the largest first. +const MAX_STYLES: usize = 200; + +/// One line of text in reading order. +struct Line { + page: usize, + text: String, + style: TextStyle, + /// Top, bottom and left of its box in user space. + top: f64, + bottom: f64, + left: f64, +} + +/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). +fn without_subset_tag(name: &str) -> &str { + match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest, + _ => name, + } +} + +/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that +/// is only whitespace, or whose size can't be read, is left out. +fn lines(doc: &Document) -> Vec { + let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES); + let mut out = Vec::new(); + for page in 0..pages { + let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue }; + let mut on_page: Vec = found + .into_iter() + .filter_map(|l| { + let text = l.text.split_whitespace().collect::>().join(" "); + // `size` is the size the line is shown at in user space (the `Tf` size times the + // text rendering matrix), so `1 Tf` with a 14× text matrix is 14. + let size = l.size; + let [x0, y0, x1, y1] = l.rect; + let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite()); + ok.then(|| Line { + page, + text, + style: TextStyle { + font: without_subset_tag(&l.base_font).to_string(), + size: (size * 10.0).round() / 10.0, + bold: l.bold, + italic: l.italic, + }, + top: y0.max(y1), + bottom: y0.min(y1), + left: x0.min(x1), + }) + }) + .collect(); + // Down the page; along each row from the left. Boxes of one row differ by a fraction + // of a point, so rows are compared at a tenth of a point. + on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left))); + out.extend(on_page); + } + out +} + +/// The styles `doc`'s text is set in, the largest size first (then the most used): headings +/// are usually larger than the text around them. +pub fn styles(doc: &Document) -> Vec { + let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + for line in lines(doc) { + match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + Some((u, last_page)) => { + u.lines = u.lines.saturating_add(1); + if *last_page != Some(line.page) { + u.pages = u.pages.saturating_add(1); + *last_page = Some(line.page); + } + } + None => { + let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); + } + } + } + let mut uses: Vec = uses.into_iter().map(|(u, _)| u).collect(); + uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines))); + uses.truncate(MAX_STYLES); + uses +} + +/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1 +/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A +/// heading set on several lines (one after another in the same style, close below each other) +/// is one bookmark. Each goes to the top of its heading. +pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { + let mut entries: Vec = Vec::new(); + // The heading the previous line belonged to: its level, and the top and bottom of its last + // line. + let mut open: Option<(usize, f64, f64)> = None; + for line in lines(doc) { + let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { + open = None; + continue; + }; + // A heading goes on to the next row down, close below its last line. A line on the same + // row (two headings side by side) is a heading of its own. + let continues = open.is_some_and(|(l, top, bottom)| { + let size = line.style.size; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + }) && entries.last().is_some_and(|e| e.page == line.page); + if continues { + if let Some(e) = entries.last_mut() + && e.title.chars().count() < MAX_TITLE_CHARS + { + e.title.push(' '); + e.title.push_str(&line.text); + e.title = e.title.chars().take(MAX_TITLE_CHARS).collect(); + } + } else { + if entries.len() >= MAX_BOOKMARKS { + break; + } + // The level counts from 1; the position in `levels` is at most its length, which a + // caller keeps small (a few levels), so it fits a u8. + let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX); + entries.push(OutlineEntry { + level, + title: line.text.chars().take(MAX_TITLE_CHARS).collect(), + page: line.page, + element: None, + top: Some(line.top), + }); + } + open = Some((level, line.top, line.bottom)); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subset_tags_are_left_out_of_the_font_name() { + assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters"); + assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters"); + assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag"); + assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x"); + } + + #[test] + fn styles_match_within_a_quarter_point() { + let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false }; + assert!(s(12.0).matches(&s(11.8))); + assert!(!s(12.0).matches(&s(11.7))); + assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) })); + assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) })); + } +} diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index cc1bbb0884..4e90c8eab6 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -509,14 +509,10 @@ fn destination_at(doc: &Document, page: usize, left: Option, top: Option Object { - go_to_at(page, None, None) -} - /// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the -/// reader's zoom; a coordinate that is left out keeps the reader's position. +/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither, +/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a +/// new bookmark when the view has no specific position). fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) @@ -560,7 +556,7 @@ pub fn add_bookmark_at( Ok(path) } -/// One bookmark of a generated tree (New Bookmarks from Structure). +/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles). #[derive(Clone, Debug, PartialEq)] pub struct OutlineEntry { /// 1 = directly under the new parent; deeper levels nest under the last shallower entry. @@ -569,6 +565,9 @@ pub struct OutlineEntry { pub page: usize, /// The structure element it stands for (`/SE`). pub element: Option, + /// Where on the page it goes: this height (user space) at the top of the window, at the + /// reader's zoom. `None` goes to the page, keeping the reader's position. + pub top: Option, } /// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by @@ -592,7 +591,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl for (e, page) in entries.iter().zip(targets) { let mut d = Dict::new(); d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title))); - d.set(b"Dest".to_vec(), go_to(page)); + d.set(b"Dest".to_vec(), go_to_at(page, None, e.top)); if let Some(se) = e.element { d.set(b"SE".to_vec(), Object::Ref(se)); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1ba8861cbe..fb9d03a7fe 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1789,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() { fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { let mut d = doc_a(); crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap(); - let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element }; + let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None }; let se = ObjRef::new(4242, 0); let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)]; assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]); diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs new file mode 100644 index 0000000000..718e582f70 --- /dev/null +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -0,0 +1,127 @@ +//! Bookmarks ▸ New bookmarks from text styles (#1693): for documents whose headings aren't +//! tagged, a style per bookmark level (the chapters' font and size for level 1, the sections' +//! for level 2, …), and every line set in it becomes a bookmark of that level +//! ([`pdfcraft_engine::text_style_bookmarks`]). + +use pdfcraft_engine::text_style_bookmarks::{TextStyle, TextStyleUse}; +use pdfcraft_engine::{DocId, Edit}; + +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The levels offered: deeper outlines are rare, and each level needs a style of its own. +const LEVELS: usize = 4; + +/// What the dialog shows and what has been chosen in it. +#[derive(Debug, Default)] +pub struct TextStyleDraft { + /// The document the styles were read from. + pub doc: Option, + /// Its text styles, the largest first. + pub styles: Vec, + /// The style (an index into `styles`) for each level, level 1 first; a level without one + /// ends the list. + pub levels: [Option; LEVELS], +} + +impl TextStyleDraft { + /// The styles for the chosen levels, level 1 first, up to the first level left without one. + pub fn chosen(&self) -> Vec { + self.levels.iter().map_while(|l| l.and_then(|i| self.styles.get(i)).map(|u| u.style.clone())).collect() + } +} + +impl PdfCraftApp { + /// Open New bookmarks from text styles for document `id`: its styles are read now, and the + /// largest is offered for level 1 when there is more than one (headings are larger than the + /// text around them; a single style has no headings to tell apart). + pub fn open_text_style_bookmarks(&mut self, id: DocId) { + let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); + let first = (styles.len() > 1).then_some(0); + self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] }; + self.dialog = Some(crate::Dialog::BookmarksFromTextStyles); + } +} + +/// How a style is named in the lists: font, size, bold or italic, how many lines, and a sample. +fn style_label(u: &TextStyleUse) -> String { + let mut s = crate::i18n::fmt(tl!("{font}, {size} pt"), &[("font", &u.style.font), ("size", &format!("{}", u.style.size))]); + if u.style.bold { + s.push_str(", "); + s.push_str(tl!("bold")); + } + if u.style.italic { + s.push_str(", "); + s.push_str(tl!("italic")); + } + let lines = if u.lines == 1 { tl!("1 line").to_string() } else { crate::i18n::fmt(tl!("{n} lines"), &[("n", &u.lines.to_string())]) }; + let sample: String = u.sample.chars().take(40).collect(); + let more = if sample.chars().count() < u.sample.chars().count() { "…" } else { "" }; + format!("{s} · “{sample}{more}” ({lines})") +} + +/// The dialog's contents and its buttons. Returns whether it closes; Create makes the +/// bookmarks and shows them in the Bookmarks panel. +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new(tl!("New bookmarks from text styles")).font(theme::semibold(18.0))); + ui.add_space(6.0); + let d = &mut app.text_style_draft; + let id = d.doc; + if d.styles.is_empty() { + ui.label(tl!("This document has no text to make bookmarks from.")); + ui.add_space(12.0); + return widgets::pill_button(ui, tl!("Close"), true).clicked(); + } + ui.label( + egui::RichText::new(tl!("Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark.")) + .color(t.text_muted), + ); + ui.add_space(8.0); + let none = tl!("None"); + egui::Grid::new("text_style_levels").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for level in 0..LEVELS { + // A level takes a style only once the one above it has one. + let open = level == 0 || d.levels.get(level - 1).copied().flatten().is_some(); + let label = ui.label(crate::i18n::fmt(tl!("Level {n}"), &[("n", &(level + 1).to_string())])); + ui.add_enabled_ui(open, |ui| { + let Some(slot) = d.levels.get_mut(level) else { return }; + let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label); + egui::ComboBox::from_id_salt(("text_style_level", level)) + .selected_text(shown) + .width(460.0) + .show_ui(ui, |ui| { + ui.selectable_value(slot, None, none); + for (i, u) in d.styles.iter().enumerate() { + ui.selectable_value(slot, Some(i), style_label(u)); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + } + }); + // A level left without a style ends the list: the ones below it don't count. + if let Some(gap) = d.levels.iter().position(Option::is_none) { + d.levels.iter_mut().skip(gap).for_each(|l| *l = None); + } + let levels = d.chosen(); + ui.add_space(16.0); + let mut close = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(!levels.is_empty(), |ui| widgets::pill_button(ui, tl!("Create"), true)).inner.clicked() { + close = true; + // The document the styles came from, even if another tab is active by now. + if let Some(i) = id.and_then(|id| app.views.iter().position(|v| v.id == id)) { + app.active = Some(i); + if app.apply_edit(Edit::BookmarksFromTextStyles { levels }) { + app.right = Some(crate::RightPanel::Bookmarks); + } + } + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + close +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c0e12d5d09..b10e16b8c8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -219,6 +219,7 @@ impl PdfCraftApp { } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), + "bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles), "edit.undo" => self.undo(), "edit.redo" => self.redo(), "edit.find" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 086dd2346c..f4100f2f05 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1129,6 +1129,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { close = apply || cancel; return; } + Dialog::BookmarksFromTextStyles => { + close = crate::bookmark_styles_ui::body(ui, app, &t); + return; + } Dialog::NumberPages => { use pdfcraft_engine::LabelStyle as L; ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4f25d6df21..e23123c3bd 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1228,6 +1228,11 @@ impl PdfCraftApp { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure } + // A dialog chooses the styles; it makes the edit. + A::FromTextStyles => { + self.open_text_style_bookmarks(id); + return; + } }; self.apply_edit(edit); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c9..ec2b596edb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,7 @@ pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep}; mod autoscroll; mod bidi; +pub mod bookmark_styles_ui; pub mod bulk_fields; mod dialogs; mod edit_text_ui; @@ -295,6 +296,8 @@ pub enum Dialog { NumberPages, /// Protect Using Password. Protect, + /// Bookmarks ▸ New bookmarks from text styles (#1693). + BookmarksFromTextStyles, /// Set Page Boxes (crop, trim, bleed, art, media). PageBoxes, /// Add / Update Header and Footer, Watermark, Background. @@ -937,6 +940,8 @@ pub struct PdfCraftApp { pub(crate) word_count_draft: word_count_ui::Draft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, + /// New bookmarks from text styles dialog state (#1693). + pub text_style_draft: bookmark_styles_ui::TextStyleDraft, /// Set Page Boxes dialog state. pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. @@ -1256,6 +1261,7 @@ impl PdfCraftApp { pending_full_screen: None, pending_remote_link: None, protect_draft: Default::default(), + text_style_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), export_draft: Default::default(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a7820c2d02..61975b275b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -734,6 +734,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromStructure); ui.close(); } + if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() { + bm_action = Some(BmAction::FromTextStyles); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), @@ -1048,6 +1052,8 @@ pub enum BmAction { Outdent(Vec), /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, + /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). + FromTextStyles, } /// Matching titles plus their ancestors, keeping document paths rather than filtered indexes. diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151bb..1019014f64 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -250,6 +250,97 @@ trailer << /Root 1 0 R >> } } +/// Two pages whose chapter and section headings are set apart only by style (#1693). +fn styled_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj +5 0 obj << /Length {} >> +stream +{page1} +endstream +endobj +6 0 obj << /Length {} >> +stream +{page2} +endstream +endobj +7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> endobj +8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj +trailer << /Root 1 0 R >> +%%EOF", + page1.len(), + page2.len() + ) + .into_bytes() +} + +/// #1693: Bookmark options ▸ New bookmarks from text styles… lists the document's styles with a +/// sample, offers the largest for level 1, and Create makes the bookmarks (sections under +/// their chapter) as one undo step. Cancel changes nothing; a document without text says so. +#[test] +fn new_bookmarks_from_text_styles_from_the_bookmark_options() { + let mut h = harness(); + h.state_mut().open_bytes("styled.pdf", None, styled_pdf()).unwrap(); + h.run_steps(3); + let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("New bookmarks from text styles…").click(); + h.run_steps(3); + }; + open(&mut h); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); + assert_eq!(h.state().text_style_draft.levels, [Some(0), None, None, None], "the largest style is offered for level 1"); + // Cancel: nothing made. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().session.get(id).unwrap().info.outline.is_empty()); + // Level 2 from its list: the sections' style. + open(&mut h); + // Each level's list is named after it, for screen readers too. + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Level 2").click(); + h.run_steps(2); + // It lists every style, by font, size, weight and a sample. + h.get_by_label("Helvetica-Bold, 18 pt, bold · “Introduction” (2 lines)"); + h.get_by_label("Helvetica, 11 pt · “Some text” (2 lines)"); + h.run_steps(2); + h.get_by_label("Helvetica-Bold, 14 pt, bold · “Background” (1 line)").click(); + h.run_steps(2); + assert_eq!(h.state().text_style_draft.levels, [Some(0), Some(1), None, None]); + h.get_by_label("Create").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let outline = h.state().session.get(id).unwrap().info.outline.clone(); + assert_eq!(outline.len(), 1); + assert_eq!(outline[0].title, "Untitled"); + let titles: Vec<(&str, Option)> = outline[0].children.iter().map(|o| (o.title.as_str(), o.page)).collect(); + assert_eq!(titles, [("Introduction", Some(0)), ("Results", Some(1))]); + assert_eq!(outline[0].children[0].children[0].title, "Background"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); + // A document with no text: nothing to choose from. + h.state_mut().open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap(); + h.run_steps(3); + open(&mut h); + let message = h.get_by_label("This document has no text to make bookmarks from.").rect(); + // The dialog's Close, below the message (the panel has a Close of its own). + h.get_all_by_label("Close").find(|n| n.rect().top() > message.bottom()).expect("the dialog's Close").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + // The command (Pages menu, command palette) opens it too. + assert!(h.state_mut().execute("bookmark.from_text_styles")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); +} + #[test] fn expanding_and_collapsing_all_bookmarks() { use pdfcraft_engine::Edit; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2c9cf17197..6f87c071e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3378,6 +3378,18 @@ tools = ["bookmark_from_structure"] evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/it/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +[[feature]] +id = "organize.bookmarks-from-text-styles" +title = "Generate bookmarks from text styles" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = ["bookmark.from_text_styles"] +tools = ["text_styles", "bookmark_from_text_styles"] +evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"] +notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From 4c0974777323afcf18dc3208aafbdb94121f2f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:51:53 +0000 Subject: [PATCH 08/75] M4: bookmarks from text styles need copying permission and keep columns apart (#1693) CodeRabbit on #4560: - The bookmark titles are the document's text, so the edit now needs copying permission as well as assembly, and Document::text_styles (the dialog's samples) is empty when copying is withheld; the automation text_styles tool already checked it. - A heading went on to the next row whenever it was close below in the same style, so same-style headings in two columns on staggered rows ran together into one bookmark. A line continues a heading only when it lies under it. - Lines found their style by a scan over every style so far; they are now indexed by font, weight, slant and quarter-point size, so a page with thousands of styles no longer makes the scan quadratic. Regression tests for the first two (each fails without its fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/engine/src/lib.rs | 16 +++++++- crates/engine/src/tests.rs | 46 +++++++++++++++++++++++ crates/engine/src/text_style_bookmarks.rs | 38 ++++++++++++++----- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 12915bc3ce..af4ba5913a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -622,7 +622,11 @@ impl Document { /// The styles the document's text is set in, the largest first, for bookmarks from text /// styles (#1693). + /// Empty when the document doesn't allow copying: the samples are its text. pub fn text_styles(&self) -> Vec { + if !self.allows_copying() { + return Vec::new(); + } self.editor.as_ref().map(|e| text_style_bookmarks::styles(&e.cos)).unwrap_or_default() } @@ -2348,7 +2352,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure - | Edit::BookmarksFromTextStyles { .. } | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -2356,6 +2359,17 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Err(EditError::NotPermitted("page changes")) } } + // Creates bookmarks (assembly), and their titles are the document's text, copied out of it + // (Table 22, bit 5), as an export of it would be. + Edit::BookmarksFromTextStyles { .. } => { + if !p.assemble() { + Err(EditError::NotPermitted("page changes")) + } else if !p.copy() { + Err(EditError::NotPermitted("copying text and images")) + } else { + Ok(()) + } + } Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0458cac519..1e7b3020fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1924,6 +1924,52 @@ fn bookmarks_from_text_styles_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document +/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. +#[test] +fn bookmarks_from_text_styles_need_copying_permission() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying()); + assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied"); + let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false }; + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("copying text and images")); + let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(!s.get(owner).unwrap().text_styles().is_empty()); + assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may"); +} + +/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower, +/// are two bookmarks, not one heading run on across the columns. +#[test] +fn headings_in_two_columns_are_two_bookmarks() { + let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET"; + let bytes = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", page) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .build(); + let mut s = Session::new(); + let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap(); + let style = s.get(id).unwrap().text_styles()[0].style.clone(); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]); +} + /// `style` at another size. fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { text_style_bookmarks::TextStyle { size, ..style.clone() } diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs index 937752fa4c..07a0cacd78 100644 --- a/crates/engine/src/text_style_bookmarks.rs +++ b/crates/engine/src/text_style_bookmarks.rs @@ -60,10 +60,11 @@ struct Line { page: usize, text: String, style: TextStyle, - /// Top, bottom and left of its box in user space. + /// Top, bottom, left and right of its box in user space. top: f64, bottom: f64, left: f64, + right: f64, } /// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). @@ -102,6 +103,7 @@ fn lines(doc: &Document) -> Vec { top: y0.max(y1), bottom: y0.min(y1), left: x0.min(x1), + right: x0.max(x1), }) }) .collect(); @@ -117,8 +119,21 @@ fn lines(doc: &Document) -> Vec { /// are usually larger than the text around them. pub fn styles(doc: &Document) -> Vec { let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + // The styles by font, weight, slant and size in quarter points, so a line is matched against + // the few styles near its size rather than every style found (a page can hold thousands). + // A size matches within a quarter point: the bucket's neighbours are looked at too. + let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec> = std::collections::HashMap::new(); + let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64; for line in lines(doc) { - match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b); + let b = bucket(line.style.size); + let found = [b.saturating_sub(1), b, b.saturating_add(1)] + .into_iter() + .filter_map(|b| index.get(&key(b))) + .flatten() + .copied() + .find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style))); + match found.and_then(|i| uses.get_mut(i)) { Some((u, last_page)) => { u.lines = u.lines.saturating_add(1); if *last_page != Some(line.page) { @@ -128,6 +143,7 @@ pub fn styles(doc: &Document) -> Vec { } None => { let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + index.entry(key(b)).or_default().push(uses.len()); uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); } } @@ -144,19 +160,21 @@ pub fn styles(doc: &Document) -> Vec { /// is one bookmark. Each goes to the top of its heading. pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { let mut entries: Vec = Vec::new(); - // The heading the previous line belonged to: its level, and the top and bottom of its last - // line. - let mut open: Option<(usize, f64, f64)> = None; + // The heading the previous line belonged to: its level, and the top, bottom, left and right + // of its last line. + let mut open: Option<(usize, f64, f64, f64, f64)> = None; for line in lines(doc) { let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { open = None; continue; }; - // A heading goes on to the next row down, close below its last line. A line on the same - // row (two headings side by side) is a heading of its own. - let continues = open.is_some_and(|(l, top, bottom)| { + // A heading goes on to the next row down, close below its last line and under it. A line + // on the same row (two headings side by side), or in another column, is a heading of + // its own. + let continues = open.is_some_and(|(l, top, bottom, left, right)| { let size = line.style.size; - l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + let under = line.left < right && left < line.right; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under }) && entries.last().is_some_and(|e| e.page == line.page); if continues { if let Some(e) = entries.last_mut() @@ -181,7 +199,7 @@ pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { top: Some(line.top), }); } - open = Some((level, line.top, line.bottom)); + open = Some((level, line.top, line.bottom, line.left, line.right)); } entries } From e3662af3a5a770fcf9276ce18babbc5e30378117 Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:56:17 +0100 Subject: [PATCH 09/75] M6: Delete removes one selected radio button, not its whole group (#4609) Prepare Form deletes the selected widget: one radio button of a group or one copy of a duplicated field, as Acrobat does; a field's last widget takes the field with it. The widget leaves its page, the field's /Kids and /CO; a per-widget /Opt loses its entry, and a group that was on only through it turns Off. form_delete_field takes an optional widget. --- crates/automation/src/forms.rs | 9 ++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 21 +++++++ crates/engine/src/lib.rs | 10 ++- crates/forms/src/author.rs | 55 ++++++++++++++-- crates/forms/src/lib.rs | 2 +- crates/forms/src/tests.rs | 80 ++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/prepare.rs | 24 ++++--- crates/ui-egui/tests/it/prepare.rs | 74 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 11 files changed, 263 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 71fbc67b3b..c428620d0f 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -676,7 +676,14 @@ impl Automation { if !self.doc(a)?.form.iter().any(|f| f.name == name) { return Err(failed(format!("there is no field named {name:?} (see form_fields)"))); } - self.apply(a, Edit::DeleteField { name }) + let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) }; + let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len()); + let widget = usize::try_from(widget) + .ok() + .and_then(|w| w.checked_sub(1)) + .filter(|w| *w < count) + .ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?; + self.apply(a, Edit::DeleteWidget { name, widget }) } pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 01c57fdaaa..4fb3d857ac 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -480,9 +480,9 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), - t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") + t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])), t( "redact_mark", "Mark for redaction", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 687dd37a4f..f226170928 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9065,3 +9065,24 @@ fn page_render_draws_stamps_without_an_appearance() { let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); } + +#[test] +fn deleting_one_radio_button_through_tools_keeps_the_group() { + let dir = workdir("delete-radio"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (x, export) in [(20, "S"), (40, "M"), (60, "L")] { + let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export }); + ok(&mut a, "form_add_field", args); + } + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } })); + ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 })); + let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone(); + assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone"); + for widget in [0, 3, -1] { + let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget })); + assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}"); + } + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f11671bf85..84c809092e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1653,6 +1653,12 @@ pub enum Edit { DeleteField { name: String, }, + /// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button + /// of a group, or one copy of a duplicated field. The last widget takes the field with it. + DeleteWidget { + name: String, + widget: usize, + }, /// Page properties ▸ Tab order, for pages (0-based). SetTabOrder { pages: Vec, @@ -2200,7 +2206,7 @@ impl Edit { Edit::ResetForm { .. } => Label::of("Clear form"), Edit::AddField { .. } => Label::of("Add field"), Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"), Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), @@ -2415,6 +2421,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::XfaToStatic | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } + | Edit::DeleteWidget { .. } | Edit::DuplicateField { .. } | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } @@ -2733,6 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } } Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, // As after a script edit: the values the new order gives show at once. diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 873ef3d716..3161209111 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1578,8 +1578,58 @@ pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormEr delete_found_field(doc, f) } +/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the +/// field and its other widgets: one radio button of a group, or one copy of a duplicated field, +/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted +/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was +/// on through the deleted widget alone turns off. +pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.get(widget).cloned() else { + return invalid(format!("{name} has no widget {}", widget.saturating_add(1))); + }; + // A field merged with its widget is that widget's only one. + if f.widgets.len() < 2 || w.obj == f.obj { + return delete_found_field(doc, f); + } + let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + let on_only_here = w.on_state.as_deref().is_some_and(|on| { + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + drop_from_pages(doc, &std::iter::once(w.obj).collect())?; + detach(doc, w.obj)?; + drop_from_calculation_order(doc, |r| r == w.obj)?; + let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut changed = false; + if per_widget_opt + && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && opt.len() == f.widgets.len() + { + let kept: Vec = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect(); + d.set(b"Opt".to_vec(), Object::Array(kept)); + changed = true; + } + if on_only_here && d.get(b"V").is_some() { + d.set(b"V".to_vec(), Object::name("Off")); + changed = true; + } + if changed { + doc.set(f.obj, Object::Dict(d)); + } + Ok(()) +} + fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + drop_from_pages(doc, &widgets)?; + detach(doc, f.obj)?; + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference. +fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet) -> Result<(), FormError> { for p in page_refs(doc) { let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); @@ -1591,10 +1641,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { } } } - detach(doc, f.obj)?; - // Out of the calculation order too: a `/CO` entry would keep the field, its value and its - // widgets reachable for a full save. - drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) + Ok(()) } /// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 2354d69f6b..9fd64d1195 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09c6e39b7a..37c7a6cbf4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4359,3 +4359,83 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and +/// the group's buttons in the calculation order; and a text field `name` with two widgets. +fn radio_group_form() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8 + "<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 12 + ]; + document(&objs) +} + +fn annots(doc: &Document) -> Vec { + let p = page_refs(doc)[0]; + let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap()); + a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect() +} + +#[test] +fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "only Medium is gone"); + assert_eq!(size.value, ["S"], "the group keeps its choice"); + // The per-widget /Opt loses Medium's entry, so the others keep their export values. + assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large"))); + assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page"); + let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap(); + assert!(co.is_empty(), "and the calculation order"); + assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched"); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value); + assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); +} + +#[test] +fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let name = field(&all, "name"); + assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::>(), name.value.clone()), (vec![11], vec!["Ann".to_string()])); + assert_eq!(annots(&doc), [6, 7, 8, 11]); + // The last widget takes the field with it, as deleting the field does. + let mut doc = doc; + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + assert!(!fields(&doc).iter().any(|f| f.name == "name")); + assert_eq!(annots(&doc), [6, 7, 8]); +} + +#[test] +fn deleting_a_widget_that_is_not_there_changes_nothing() { + let mut doc = radio_group_form(); + assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into()))); + assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into()))); + assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX)))); + assert_eq!(field(&fields(&doc), "size").widgets.len(), 3); +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9a13d570d9..d1a168554e 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1075,6 +1075,7 @@ fn is_form_edit(edit: &Edit) -> bool { | Edit::ResetForm { .. } | Edit::AddField { .. } | Edit::DeleteField { .. } + | Edit::DeleteWidget { .. } | Edit::DuplicateField { .. } | Edit::SetFieldProps { .. } | Edit::SetFieldImage { .. } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 9f58f5ea5e..6faa3afe8e 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -597,20 +597,24 @@ pub(crate) fn paint_page( } /// Delete the selected field and every field selected with it (#95), as one undoable step, and -/// clear the selection. +/// clear the selection. Each selection is one widget, so selecting one radio button of a group, +/// or one copy of a duplicated field, deletes only that one, as in Acrobat (#4609); a field's +/// last widget takes the field with it. pub(crate) fn delete_selected(view: &mut DocView) -> Option { - let (first, _) = view.prepare.selected.take()?; - let mut names = vec![first]; - for (name, _) in std::mem::take(&mut view.prepare.also) { - if !names.contains(&name) { - names.push(name); + let first = view.prepare.selected.take()?; + let mut picked = vec![first]; + for w in std::mem::take(&mut view.prepare.also) { + if !picked.contains(&w) { + picked.push(w); } } - if names.len() == 1 { - return names.pop().map(|name| Edit::DeleteField { name }); + // A field's later widgets first, so the indices of the ones still to go don't shift. + picked.sort_by_key(|w| std::cmp::Reverse(w.1)); + if picked.len() == 1 { + return picked.pop().map(|(name, widget)| Edit::DeleteWidget { name, widget }); } - let label = format!("Delete {} fields", names.len()); - Some(Edit::Batch { label, edits: names.into_iter().map(|name| Edit::DeleteField { name }).collect() }) + let label = format!("Delete {} fields", picked.len()); + Some(Edit::Batch { label, edits: picked.into_iter().map(|(name, widget)| Edit::DeleteWidget { name, widget }).collect() }) } /// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat, diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 936b847d82..e121787f62 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1218,3 +1218,77 @@ fn a_loop_has_its_own_card_that_opens_the_formula() { let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab)); assert_eq!(draft, Some(("a".to_owned(), FieldTab::Calculate))); } + +/// A radio group `shirt` of three buttons, S, M and L, with L chosen, while preparing the form. +fn radio_group_harness() -> Harness<'static, PdfCraftApp> { + let mut h = harness(); + for (x, export) in [(20.0, "S"), (60.0, "M"), (100.0, "L")] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [x, 300.0, x + 14.0, 314.0], + kind: pdfcraft_engine::NewField::Radio { group: Some("shirt".into()), export: export.into() }, + name: Some("shirt".into()), + }); + } + h.state_mut() + .apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "shirt".into(), value: pdfcraft_engine::FieldValue::Radio(Some("L".into())) }); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(3); + h +} + +fn buttons(h: &Harness<'static, PdfCraftApp>) -> (Vec, Vec) { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "shirt").expect("the group is still there"); + (f.widgets.iter().filter_map(|w| w.on_state.clone()).collect(), f.value.clone()) +} + +/// #4609: Delete removes the one radio button clicked, not its whole group, as in Acrobat. +#[test] +fn deleting_one_radio_button_keeps_the_rest_of_its_group() { + let mut h = radio_group_harness(); + assert_eq!(buttons(&h).0, ["S", "M", "L"]); + let p = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "shirt").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 1).expect("on screen").center() + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().views[0].prepare.selected, Some(("shirt".into(), 1)), "the middle button is selected"); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(buttons(&h), (vec!["S".into(), "L".into()], vec!["L".into()]), "only M is gone; L stays chosen"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete field")); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(buttons(&h).0, ["S", "M", "L"]); +} + +/// Two buttons of one group selected together both go, whichever was selected first. +#[test] +fn deleting_two_radio_buttons_of_a_group_at_once() { + let mut h = radio_group_harness(); + { + let p = &mut h.state_mut().views[0].prepare; + p.selected = Some(("shirt".into(), 0)); + p.also = vec![("shirt".into(), 2)]; + } + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(buttons(&h), (vec!["M".into()], vec![]), "M is left, and nothing is chosen"); + // Selecting every button deletes the group. + { + let p = &mut h.state_mut().views[0].prepare; + p.selected = Some(("shirt".into(), 0)); + } + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert!(!names(&h).iter().any(|n| n == "shirt")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69c98d076a..44c12380ad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4811,8 +4811,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" +evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::deleting_one_radio_button_keeps_the_rest_of_its_group", "crates/ui-egui/tests/it/prepare.rs::deleting_two_radio_buttons_of_a_group_at_once", "crates/forms/src/tests.rs::deleting_one_radio_button_keeps_the_group_and_its_other_buttons", "crates/forms/src/tests.rs::deleting_the_chosen_radio_button_turns_the_group_off", "crates/forms/src/tests.rs::deleting_one_copy_of_a_field_keeps_the_field_and_its_value", "crates/automation/tests/it/automation.rs::deleting_one_radio_button_through_tools_keeps_the_group", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step; a selected radio button or copy of a duplicated field goes on its own and the rest of its group stays, a field's last widget takes the field, #4609); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] id = "form.add-text-field" From f75af23d4ea06a15b10b3d021de8159bf149eef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:26:51 +0000 Subject: [PATCH 10/75] M6: deleting a radio button turns the group off when no button left shows its choice CodeRabbit on #4631: a twin with the same on-state name that is currently off does not keep the choice visible, so compare the remaining buttons' appearance state (/AS), not their on-state name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/forms/src/author.rs | 4 +++- crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 3161209111..81e7ec9dc4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1593,8 +1593,10 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() return delete_found_field(doc, f); } let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + // Turn the group off when no button left *shows* the deleted one's state: a twin with the same + // on-state name that is currently off does not keep the choice visible. let on_only_here = w.on_state.as_deref().is_some_and(|on| { - f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on)) }); drop_from_pages(doc, &std::iter::once(w.obj).collect())?; detach(doc, w.obj)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 37c7a6cbf4..3e919ec7c5 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4414,6 +4414,28 @@ fn deleting_the_chosen_radio_button_turns_the_group_off() { assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); } +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() { + // Medium shares Small's on-state name but shows Off: once Small is gone, no button shows + // the group's /V, so the group must not keep reporting it. + let mut doc = radio_group_form(); + let twin = ObjRef::new(7, 0); + let blank = Object::Ref(ObjRef::new(12, 0)); + let mut n = Dict::new(); + n.set(b"S".to_vec(), blank.clone()); + n.set(b"Off".to_vec(), blank); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = doc.get(twin).as_dict().cloned().unwrap(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + doc.set(twin, Object::Dict(w)); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); +} + #[test] fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { let mut doc = radio_group_form(); From 9fb07e4ac7ccc4065867f354b8d6d05975c90160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:39:30 +0000 Subject: [PATCH 11/75] M6: deleting the default radio button clears the group's default too CodeRabbit on #4631: with /V already Off, a /DV naming the deleted button's state was kept, so Reset would put back a state no button has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/forms/src/author.rs | 9 +++++++++ crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index eccd9a34e2..8165f56afc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1616,6 +1616,15 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() d.set(b"V".to_vec(), Object::name("Off")); changed = true; } + // The default (`/DV`, what Reset puts back) can't name a state no button has any more. + let dv_only_here = w.on_state.as_deref().is_some_and(|on| { + d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes()) + && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + if dv_only_here { + d.set(b"DV".to_vec(), Object::name("Off")); + changed = true; + } if changed { doc.set(f.obj, Object::Dict(d)); } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f52be5306d..b85aeb726c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4654,6 +4654,28 @@ fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); } +#[test] +fn deleting_the_default_radio_button_clears_the_default_too() { + // The group is off, but Reset would put back Small, which no button has once it is gone. + let mut doc = radio_group_form(); + let size = ObjRef::new(5, 0); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"V".to_vec(), Object::name("Off")); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 0).unwrap(); + let doc = reopen(&doc); + let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()); + assert_eq!(dv, Some(Object::name("Off"))); + // A default another button still has stays. + let mut doc = radio_group_form(); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 1).unwrap(); + assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S"))); +} + #[test] fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { let mut doc = radio_group_form(); From 2b83ae6a7a281e80bc8f8ea205ae36c4c59a46af Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 21:01:58 +0100 Subject: [PATCH 12/75] fix(ui): Update previews the page without the marks it replaces Update Background, Update Watermark and Update Header and Footer previewed the new marks on the page's thumbnail, which still has the marks Update replaces. A pale yellow background updated to light blue showed a muddy teal (the blue multiplied onto the yellow), and a new watermark or header was drawn over the old one. The applied result was right; only the preview was wrong. In Update, the preview now draws the page without its marks of that kind. The engine removes them on a copy of the document (Document::page_without_marks, read-only like the other preview layers), and the dialog renders that page off the UI thread at the preview's size, showing a blank sheet until it arrives. Add, and pages without marks of that kind, keep the thumbnail. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 36 +++++++ crates/engine/src/text_preview.rs | 21 +++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/marks_ui.rs | 79 ++++++++++++++- crates/ui-egui/tests/it/marks.rs | 153 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +-- 7 files changed, 294 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 360517a87c..4c061ba809 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2999,6 +2999,42 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// Update previews its marks on the page without the ones it replaces: a yellow background +/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. +/// The other kinds of mark and the page's own content stay, a page without that kind has nothing +/// to prepare, and preparing changes nothing in the document. +#[test] +fn update_previews_on_the_page_without_the_marks_it_replaces() { + let (mut s, id) = session_with(2); + let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: yellow, replace: false, file: None }).unwrap(); + let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap() + }; + let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count(); + let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); + assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); + + let page = doc.page_without_marks(0, MarkKind::Background).unwrap().expect("page 1 has a background").render_background(1.0).unwrap(); + assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); + assert!(reds(&page) > 50, "the watermark stays"); + assert!(dark(&page) > 10, "the page text stays"); + + let page = doc.page_without_marks(0, MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_background(1.0).unwrap(); + assert_eq!(reds(&page), 0, "without the watermark"); + assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); + + assert!(doc.page_without_marks(1, MarkKind::Background).unwrap().is_none(), "page 2 has none: its thumbnail is the page"); + assert!(doc.page_without_marks(0, MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(doc.page_without_marks(9, MarkKind::Background).is_err(), "no page 10"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); +} + #[test] fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index 290231cf73..d19272fcef 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -1,4 +1,5 @@ -//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and, +//! the same way, the page beneath a dragged added item or the marks an Update replaces. use std::sync::Arc; @@ -90,6 +91,24 @@ impl Document { })? } + /// The page without its marks of `kind`: what Update replaces them on, so its preview doesn't + /// draw the new marks over the old ones (or tint the old background). `None` when the page has + /// none, and its thumbnail already is that page. Read-only, like [`Self::text_edit_preview`]. + pub fn page_without_marks(&self, page: usize, kind: crate::MarkKind) -> Result, String> { + if !self.marks().contains(&kind) { + return Ok(None); + } + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + if pdfcraft_edit::remove_marks(&mut cos, &[page], kind).map_err(|e| e.to_string())? == 0 { + return Ok(None); + } + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(Some(TextEditPreview { page, bytes, config: self.config.clone() })) + })? + } + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, /// so the page under its first place doesn't come along. Read-only, like /// [`Self::added_content_preview`]. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b3437246a0..85b8a11c24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1586,6 +1586,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; + app.unmarked_page = Default::default(); } else { app.dialog = Some(next); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..e7452210bb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -955,6 +955,8 @@ pub struct PdfCraftApp { pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. pub marks_draft: marks_ui::MarksDraft, + /// The Update dialogs' preview page without the marks they replace. + pub(crate) unmarked_page: marks_ui::UnmarkedPage, /// Export dialog settings. pub export_draft: export_ui::ExportDraft, /// A running export's progress. @@ -1279,6 +1281,7 @@ impl PdfCraftApp { protect_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), + unmarked_page: Default::default(), export_draft: Default::default(), export_status: None, signature: None, @@ -2878,7 +2881,7 @@ impl PdfCraftApp { /// `true` while any open document still waits for page renders (used by headless capture). pub fn render_pending(&self) -> bool { - self.views.iter().any(|v| v.render_pending()) + self.views.iter().any(|v| v.render_pending()) || self.unmarked_page.pending() } /// Apply a named view option (`--page 3`, `--panel bookmarks`, `--theme dark`, …). diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 5340cef393..1b6f66c2d1 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -12,6 +12,9 @@ use crate::{PdfCraftApp, icons, widgets}; /// How far a watermark may be moved from the page centre, in points. const MAX_OFFSET: f64 = 20_000.0; +/// The preview page's longer side, in points. +const PREVIEW_SIDE: f32 = 190.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Subset { All, @@ -152,7 +155,7 @@ fn preview( draw: impl FnOnce(&egui::Painter, Rect, f32), ) { let (w, h) = (size.0.max(1.0), size.1.max(1.0)); - let s = (190.0 / w.max(h)) as f32; + let s = (f64::from(PREVIEW_SIDE) / w.max(h)) as f32; let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover()); let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); let painter = ui.painter_at(r); @@ -180,6 +183,69 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } +/// In Update, the page previewed without the marks the dialog replaces. Its thumbnail still has +/// them, so the new marks would be drawn over the old ones, and a new background colour would tint +/// the old one. Rendered off the UI thread like the thumbnail, once per page shown. +#[derive(Default)] +pub(crate) struct UnmarkedPage { + /// The document, its edit generation, the page and the kind of mark taken away. + key: Option<(pdfcraft_engine::DocId, u64, usize, MarkKind)>, + renderer: Option, + texture: Option, + tag: Option, +} + +impl UnmarkedPage { + pub(crate) fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + /// The picture to preview `page` on in place of its thumbnail: `None` when the thumbnail is + /// that page (not Update, or no marks of `kind` on it, or the page can't be prepared), and + /// `Some(None)` while it renders, when the page is a blank sheet. + fn picture( + &mut self, + ctx: &egui::Context, + (doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId), + page: usize, + kind: MarkKind, + replace: bool, + ) -> Option> { + let key = replace.then(|| (id, doc.edit_generation(), page, kind)); + if self.key != key { + *self = Self { key, ..Default::default() }; + if replace { + self.renderer = doc.page_without_marks(page, kind).ok().flatten().map(|p| p.renderer()); + } + } + let renderer = self.renderer.as_ref()?; + // At the preview's size in pixels. + let p = doc.info.pages.get(page)?; + let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // The thumbnail, as before, rather than no page at all. + self.renderer = None; + self.texture = None; + return None; + } + if out.request.tag == tag { + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + self.texture = Some(ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR)); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + Some(self.texture.as_ref().map(|t| t.id())) + } +} + /// The page (0-based) the preview shows: the current one when the range selects it, otherwise the /// first page the range selects (the current one again when it selects none). fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { @@ -378,12 +444,15 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.add_space(8.0); // The preview: the current page (or the page stepped to), or the first one the range selects // if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the - // frame the engine lays the marks out in. + // frame the engine lays the marks out in. Update draws it without the marks it replaces. let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); - let picture = app.views.get_mut(i).and_then(|view| { - view.need_thumbnail(target, true); - view.thumb_id(target) + let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); + let picture = unmarked.unwrap_or_else(|| { + app.views.get_mut(i).and_then(|view| { + view.need_thumbnail(target, true); + view.thumb_id(target) + }) }); let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; let hf = d.hf.clone(); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..e833aa0fae 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -294,6 +294,159 @@ fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() { h.get_by_label("Preview: page 2 of 3"); } +/// Keeps every texture the app uploads, so a test can read what a picture it paints shows. +#[derive(Clone, Default)] +struct Uploads(std::sync::Arc>>>); + +impl egui_kittest::TestRenderer for Uploads { + fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) { + let mut seen = self.0.lock().unwrap(); + for (id, images) in delta.set.drain() { + for d in images.into_iter().filter(|d| d.pos.is_none()) { + let egui::ImageData::Color(image) = d.image; + seen.insert(id, image); + } + } + // Freed textures stay readable: ids are never reused. + delta.free.clear(); + } + + fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result { + Err("this renderer only keeps the uploads".into()) + } +} + +/// The page picture the dialog's preview painted last frame, left of its "Preview: page …" label, +/// once its texture is in: its tint and its image. +fn preview_picture(h: &Harness<'static, PdfCraftApp>, uploads: &Uploads) -> Option<(egui::Color32, std::sync::Arc)> { + let label = h.query_by_label_contains("Preview: page")?.rect(); + h.output().shapes.iter().find_map(|s| { + let egui::Shape::Mesh(m) = &s.shape else { return None }; + let r = m.calc_bounds(); + // The size of the 210-point box left of the label (which is centred down its side), and + // not the stepper's arrow beside the label. + let beside = r.width() > 50.0 + && r.height() <= 215.0 + && r.right() <= label.left() + && label.left() - r.right() < 260.0 + && r.top() <= label.bottom() + && r.bottom() >= label.top(); + if !beside { + return None; + } + let image = uploads.0.lock().unwrap().get(&m.texture_id).cloned()?; + Some((m.vertices.first()?.color, image)) + }) +} + +/// [`preview_picture`], waiting until `ready` accepts it (the page renders off the UI thread). +fn wait_for_preview( + h: &mut Harness<'static, PdfCraftApp>, + uploads: &Uploads, + what: &str, + ready: impl Fn(&egui::ColorImage) -> bool, +) -> (egui::Color32, std::sync::Arc) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + h.step(); + let shown = preview_picture(h, uploads); + if let Some((tint, image)) = shown.clone().filter(|(_, image)| ready(image)) { + return (tint, image); + } + assert!( + std::time::Instant::now() < deadline, + "the preview never showed {what}; last (tint, size, middle of the lower half): {:?}", + shown.map(|(tint, i)| (tint, i.size, at(&i, 0.5, 0.75))) + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// The pixel of `image` at (u, v), from 0 to 1 across and down. +fn at(image: &egui::ColorImage, u: f32, v: f32) -> egui::Color32 { + let [w, h] = image.size; + let (x, y) = (((u * w as f32) as usize).min(w - 1), ((v * h as f32) as usize).min(h - 1)); + image.pixels[y * w + x] +} + +fn count(image: &egui::ColorImage, f: impl Fn(egui::Color32) -> bool) -> usize { + image.pixels.iter().filter(|&&c| f(c)).count() +} + +/// Update draws its preview on the page without the marks it replaces, which the page's thumbnail +/// still has: a pale yellow background updated to light blue previews light blue, not blue +/// multiplied onto the yellow, and a new watermark or header isn't drawn over the old one. The +/// page's content and its other kinds of mark stay. +#[test] +fn update_previews_the_page_without_the_marks_it_replaces() { + let uploads = Uploads::default(); + let pdf = content_fixture(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).renderer(uploads.clone()).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); + app + }); + h.run_steps(4); + let yellow = egui::Color32::from_rgb(255, 255, 153); + let red = |c: egui::Color32| c.r() > 200 && c.g() < 80 && c.b() < 80; + let blue = |c: egui::Color32| c.b() > 150 && c.r() < 100 && c.g() < 100; + let dark = |c: egui::Color32| c.r() < 60 && c.g() < 60 && c.b() < 60; + let apply = |h: &mut Harness<'static, PdfCraftApp>, command: &str, draft: &dyn Fn(&mut pdfcraft_ui_egui::marks::MarksDraft)| { + assert!(h.state_mut().execute(command)); + h.run_steps(2); + draft(&mut h.state_mut().marks_draft); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + }; + // A pale yellow background, a red watermark and a blue header. + apply(&mut h, "edit.background", &|d| d.bg.color = [1.0, 1.0, 0.6]); + apply(&mut h, "edit.watermark", &|d| { + d.wm.text = "OLD".into(); + d.wm.color = [1.0, 0.0, 0.0]; + d.wm.opacity = 1.0; + d.wm.rotation = 0.0; + }); + apply(&mut h, "edit.header_footer", &|d| { + d.hf.text[1] = "OLD".into(); + d.hf.color = [0.0, 0.0, 1.0]; + d.hf.font_size = 40.0; + }); + // Add keeps the marks there, and previews on the thumbnail, which has all three. + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + let all = |i: &egui::ColorImage| at(i, 0.5, 0.75) == yellow && count(i, red) > 200 && count(i, blue) > 40; + wait_for_preview(&mut h, &uploads, "the page with its marks", all); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.background.update")); + h.run_steps(2); + h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; + let (tint, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); + assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); + assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.watermark.update")); + h.run_steps(2); + h.state_mut().marks_draft.wm.text = "NEW".into(); + let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); + assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + assert!(h.state_mut().execute("edit.header_footer.update")); + h.run_steps(2); + let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); + assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); +} + /// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on /// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on /// the last page. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a0dadf4adc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2423,8 +2423,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded" +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces" [[feature]] id = "edit.header-footer-update-remove" @@ -2483,8 +2483,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there)" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces" [[feature]] id = "edit.watermark-add-file" @@ -2531,8 +2531,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background"] tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name" +evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name; Update previews on the page without the backgrounds it replaces, not tinting the old one" [[feature]] id = "edit.background-update-remove" From 24eb327a809c177f183bd697068fb4d90121c89d Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:57:29 +0200 Subject: [PATCH 13/75] fix(ui): the page sets its cursor once, from the top claim the tool reaches Every page module set egui's cursor itself and the last write of a frame won, so the order of document_area's per-page chain decided the cursor, not what a press there does. The link hover loop ran last and showed the hand over any link for every tool but Fill & Sign, though only the Select tool follows links: a click with the stamp tool placed a stamp, a drag with the rectangle tool drew, and in Edit text a drag from linked words moved the paragraph, all under the hand. The text layer ran after the comments and showed the I-beam over a movable comment lying on text, which a drag there moves. Page modules now claim the cursor instead (src/page_cursor.rs): a layer, an icon and optionally a tip. The layers are the review's priority table, Editor > Handle > Placed > Field > Comment > Link > Text > tool area, with Drag on top for a drag in progress, whose owner names the cursor until the release. document_area resolves the claims once per frame, after the pages, and calls set_cursor_icon once: the claim on the highest layer the active tool reaches wins. Links count only for the Select tool (follow) and the Link tool (edit), comments only for the Select tool; the other layers are claimed only by the modules of the tools that act on them. The claims go into a per-frame collector on DocView, which every page module already takes, so the calls in the dispatch block keep their arguments (camera, content and redact lose a `ui` they took only for the cursor). A link's tip goes with its claim, so it shows only where the link wins: no longer with a stamp, drawing or other tool that does not follow links. A comment's tip is a description instead (comments::describe), shown with any tool beside that tool's cursor unless the winning claim has a tip of its own: Acrobat shows a comment's tooltip with every tool (observed with Select, Hand, Add comment, Highlight, Rectangle and Stamp). It keeps the old exceptions (Fill & Sign, freehand drawing for #429, a comment gesture in progress) and comments. A signed field's validation badge (#1895) says its verdict the same way, with any tool, ranked below a comment's description as it was below the comment's tip; the topmost of overlapping badges still speaks. FieldPointer::Over existed only to keep the text layer from overwriting a field's cursor (#2099); the Field layer above Text does that now, so forms_ui::page_input returns whether a field owns the press. Who acts on a press is unchanged (step 3 of the router). The picture's rotate knob claims too: Grab as a handle, Grabbing as a drag while it turns, which dragging_the_rotate_knob_turns_a_page_image now checks. The contains_pointer checks #2755 put before the cursor in content_ui, edit_text_ui, forms_ui and the text layer go: the pages' pointer has no position where the mode bar or a side panel covers a page, so nothing is claimed there, and #2755's tests pass unchanged. During a press the pages hold, those four now keep their cursor over the chrome too, as the other page modules and a text selection being dragged already did. Un-ignores the four step-2 probes in tests/it/pointer.rs, each seen failing on the previous commit at its cursor assertion, after its press assertions passed. A new test, also seen failing there: a link's tip shows only with its hand. Another pins a comment's tip with every tool beside its cursor, and its exceptions; it was seen failing with comment tips tied to the cursor's winner instead, which shows them with the Select tool only. In tests/it/signing.rs, a signed field's badge says its verdict with the Select tool and with a stamp; it fails when the badge is left out of the resolver, as resolving after the page loop would otherwise do. A new ignored probe pins a step-3 bug: over stacked comments the tip names the one drawn on top, but a click selects the one underneath, because PageCx::hit takes the last in inspection's top-edge order. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_ui.rs | 9 +- crates/ui-egui/src/canvas.rs | 139 +++--------- crates/ui-egui/src/comments.rs | 126 +++++++++-- crates/ui-egui/src/content_ui.rs | 17 +- crates/ui-egui/src/crop.rs | 3 +- crates/ui-egui/src/edit_text_ui.rs | 59 ++--- crates/ui-egui/src/fill_sign.rs | 5 +- crates/ui-egui/src/forms_ui.rs | 41 ++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/link_ui.rs | 6 +- crates/ui-egui/src/measure_ui.rs | 4 +- crates/ui-egui/src/object_ui.rs | 5 +- crates/ui-egui/src/page_cursor.rs | 332 +++++++++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 7 +- crates/ui-egui/src/redact_ui.rs | 4 +- crates/ui-egui/src/sign_ui.rs | 3 +- crates/ui-egui/src/zoom_snap.rs | 3 +- crates/ui-egui/tests/it/editing.rs | 2 + crates/ui-egui/tests/it/pointer.rs | 93 +++++++- crates/ui-egui/tests/it/signing.rs | 56 ++++- parity/acrobat-features.toml | 4 +- 21 files changed, 693 insertions(+), 226 deletions(-) create mode 100644 crates/ui-egui/src/page_cursor.rs diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index d470378070..abaad1ed5c 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -5,6 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,11 +60,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::NotAllowed); } return; } @@ -93,7 +94,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); if let Some(i) = q.grab { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { let (x, y) = xf.screen_to_view(p); if let Some(c) = q.corners.get_mut(i) { @@ -104,7 +105,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p q.grab = None; } } else if near { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4f00aa6bec..1b9dce8285 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; +use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -641,6 +642,8 @@ pub struct DocView { /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. pub(crate) fill_signature_page: Option, pub(crate) signature_drag: crate::signature_drag::SignatureDrag, + /// What the page modules claim for the cursor this frame; `document_area` sets it once. + pub(crate) cursor: crate::page_cursor::PageCursor, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they @@ -947,6 +950,7 @@ impl DocView { fill_text: None, fill_signature_page: None, signature_drag: Default::default(), + cursor: Default::default(), } } @@ -2500,17 +2504,6 @@ impl DocView { } } -/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) -/// show none: they are page content being edited, not comments. -fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { - if a.is_reply() || a.fill_sign { - return None; - } - let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); - let body = a.contents.clone().unwrap_or_default(); - Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) -} - /// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within /// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed /// the page misplaced for a frame and then jumped (zooming out beside a page that fits). @@ -3309,7 +3302,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; - let mut badge_hover: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; @@ -3379,9 +3371,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); } else if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab); } } let origin = resp_rect.min - vec2(0.0, y_shift); @@ -3413,7 +3405,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let mut calc_hover = None; for &i in &visible_pages { - badge_hover = None; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { continue; @@ -3578,7 +3569,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && allowed && let Some(p) = ptr.within(xf.rect) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.clicked() { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); @@ -3602,7 +3593,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && allowed && let Some(p) = ptr.within(xf.rect) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.clicked() { // Centred on the click at its natural size (the engine sizes it). let (vx, vy) = xf.screen_to_view(p); @@ -3657,14 +3648,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); } if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); + crate::camera_ui::page_input(&resp, &xf, &ptr, i, view, can_modify); } if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { view.current = i; open_boxes = true; } - // `over_field`: a form field is under the pointer and has set its cursor. - let (on_field, over_field) = if preparing { + let on_field = if preparing { let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, @@ -3675,18 +3665,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { field_props |= o.properties; field_placed |= o.placed; open_table = open_table.or(o.table); - (o.consumed || field_tool.is_some(), false) + o.consumed || field_tool.is_some() } else { - use crate::forms_ui::FieldPointer; - let field = if tool == QuickTool::Select + tool == QuickTool::Select && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) && !comments::select_grabs(&pcx, view) - { - crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) - } else { - FieldPointer::Away - }; - (field == FieldPointer::Owned, field == FieldPointer::Over) + && crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -3696,7 +3680,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) + crate::redact_ui::page_input(&resp, &xf, &ptr, i, info, over_text, view) }; let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); let on_objects = @@ -3729,7 +3713,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let on_content = !on_objects && editing_content && can_modify && { source_blocked || crate::content_ui::page_input( - ui, &resp, &xf, &ptr, @@ -3845,15 +3828,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Column select (#740): the Column select tool, or Alt (Option on macOS) held // while a drag starts, as in Acrobat. let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); - // A link or a form field under the pointer keeps its own cursor: a check box - // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - // So do the mode bar and side panels over a page scrolled under them (#2755), - // unless a selection is being dragged there. - let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); - if column && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } else if over_text && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + // A press on a link selects nothing (it is the link's), so no cursor here says + // it would. + if column && !over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if over_text && !over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); } // The pages' press, kept when its release came in the same frame (#2100): it // still starts multi-click selection. @@ -4029,16 +4009,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips - // draw above the palette, so none shows while it is open, even beside it. - if !matches!(tool, QuickTool::Fill(_)) - && unobstructed - && let Some(p) = pointer - { + // Links: the hand and the link's target, which count only for the tools that follow + // or edit links (`Layer::reached_by`). Tips draw above the palette, so none shows + // while it is open, even beside it. + if unobstructed && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { let sr = xf.user_rect(info, i, l.rect); if sr.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); let label = match &l.target { LinkTarget::Page(n, _) => { crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) @@ -4048,34 +4025,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { LinkTarget::RemoteGoTo { file, .. } => file.clone(), LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), }; - hover_text = Some((p, label)); + view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label)); if resp.clicked() && tool == QuickTool::Select && !consumed { clicked_link = Some(l.target.clone()); } } } - // Annotation hover shows the comment, as Acrobat's popups do; not while drawing - // freehand, where it would cover the next stroke (#429). - let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); - let gesturing = view.comments.gesture.is_some() || freehand; - // Inspection sorts annotations spatially, but the renderer paints in /Annots - // order. Keep the shared list intact and choose the last eligible raw index. - if hover_text.is_none() - && !gesturing - && let Some((_, text)) = info - .annotations - .iter() - .filter(|a| a.page == i) - .filter(|a| { - !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) - }) - .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) - .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) - .max_by_key(|(index, _)| *index) - { - hover_text = Some((p, text)); - } } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); for (mp, mr, mc) in &view.compare_marks { if *mp == i { let sr = xf.user_rect(info, i, *mr).expand(1.5); @@ -4120,21 +4078,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); let mark = Rect::from_center_size(sr.center(), vec2(side, side)); icons::paint(ui, mark, icon, side * 0.64, color); - if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { - badge_hover = Some((p, badge.summary.to_string())); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, + // with any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); } } - // Overlapping widgets paint in order, so the topmost badge owns the hover; an - // earlier link or annotation hover still wins over any badge. - if hover_text.is_none() { - hover_text = badge_hover.take(); - } } if calc_order { view.calc_order.set_hover_page(calc_hover); let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } + // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. + hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole @@ -6044,36 +6001,6 @@ mod tests { } } - fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation { - pdfcraft_render::Annotation { - page: 0, - subtype: "FreeText".into(), - author: None, - contents: Some("Hello".into()), - modified: None, - name: None, - in_reply_to: None, - parent: None, - grouped: false, - rect: [0.0; 4], - color: None, - index: 0, - state: None, - quads: Vec::new(), - locked: false, - intent: None, - fill_sign, - no_rotate: false, - points: Vec::new(), - } - } - - #[test] - fn hover_popup_skips_fill_and_sign_marks() { - assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello")); - assert_eq!(annotation_hover_text(&annotation(true)), None); - } - fn signature(signed: bool, visible: bool, page: Option, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo { pdfcraft_engine::SignatureInfo { field: "Signature1".into(), diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index bf9b5fe72c..927d1b511c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{QuickTool, icons}; @@ -842,7 +843,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && pressed_here @@ -901,7 +902,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. @@ -961,7 +962,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && pressed_here @@ -995,7 +996,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.clicked() && over_page @@ -1011,7 +1012,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); } if resp.clicked() && over_page @@ -1030,7 +1031,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, return false; } if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its // text wraps inside it (#2669). @@ -1146,6 +1147,46 @@ fn clamp_to(r: Rect, p: Pos2) -> Pos2 { pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom())) } +/// Whether `p` is on one of this page's links. +fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { + cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) +} + +/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat +/// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays +/// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a +/// comment is moved, resized or drawn. +pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { + let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser)); + if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() { + return; + } + let Some(p) = cx.ptr.pos else { return }; + // Inspection sorts comments by position, but the renderer paints them in `/Annots` order: the + // tip is that of the one drawn on top, among those with something to say (#3620). + let top = cx + .comments() + .filter(|a| cx.screen_rects(a).any(|r| r.contains(p))) + .filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text))) + .max_by_key(|(index, _)| *index); + if let Some((_, text)) = top { + view.cursor.describe(Subject::Comment, text); + } +} + +/// What hovering `a` says, as Acrobat's popups do. Replies and Fill & Sign's marks (typed text, +/// checks, signatures) say nothing: they are page content being edited, not comments. Neither +/// does a highlight with no text of its own, when the preferences say so. +fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option { + let empty_highlight = a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); + if a.is_reply() || a.fill_sign || prefs.hide_empty_highlight_hover && empty_highlight { + return None; + } + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + let body = a.contents.clone().unwrap_or_default(); + Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) +} + /// The Select tool: hover, select, move, resize, edit, delete. fn select_input( ui: &egui::Ui, @@ -1170,17 +1211,26 @@ fn select_input( let mut consumed = false; if let Some(p) = pointer { if point_at(p).is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Crosshair); } else if let Some(h) = handle_at(p) { - ui.ctx().set_cursor_icon(match h { - (0, _) => egui::CursorIcon::ResizeVertical, - (_, 0) => egui::CursorIcon::ResizeHorizontal, - (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe, - _ => egui::CursorIcon::ResizeNeSw, - }); - } else if let Some(a) = cx.hit(p) { + view.cursor.claim( + Layer::Handle, + match h { + (0, _) => egui::CursorIcon::ResizeVertical, + (_, 0) => egui::CursorIcon::ResizeHorizontal, + (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe, + _ => egui::CursorIcon::ResizeNeSw, + }, + ); + } else if let Some(a) = cx.hit(p) + // A click on a highlight over a link follows the link, so the link's hand and tip + // show there. + && !(a.subtype == "Highlight" && on_link(cx, p)) + { let movable = cx.allowed && !is_markup(&a.subtype); - ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }); + let icon = if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; + // Signatures and Fill & Sign's marks are placed on the page rather than comments on it. + view.cursor.claim(if a.fill_sign { Layer::Placed } else { Layer::Comment }, icon); } } if resp.drag_started() @@ -1254,12 +1304,8 @@ fn select_input( // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { - let follow_link = cx.tool == QuickTool::Select - && !consumed - && cv.gesture.is_none() - && !resp.double_clicked() - && pointer - .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))); + let follow_link = + cx.tool == QuickTool::Select && !consumed && cv.gesture.is_none() && !resp.double_clicked() && pointer.is_some_and(|p| on_link(cx, p)); match pointer.and_then(|p| cx.hit(p)) { Some(a) if a.subtype == "Highlight" && follow_link => { // A plain click follows the link beneath text highlighting. Keep other @@ -2383,6 +2429,44 @@ mod tests { PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] } } + fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation { + Annotation { + page: 0, + subtype: subtype.into(), + author: None, + contents: contents.map(str::to_string), + modified: None, + name: None, + in_reply_to: None, + parent: None, + grouped: false, + rect: [0.0; 4], + color: None, + index: 0, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign, + no_rotate: false, + points: Vec::new(), + } + } + + #[test] + fn the_hover_tip_skips_fill_and_sign_marks_replies_and_bare_highlights_when_asked() { + let prefs = CommentPrefs::default(); + assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), false), &prefs).as_deref(), Some("FreeText\nHello")); + assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), true), &prefs), None); + let reply = Annotation { in_reply_to: Some("1 0 R".into()), parent: Some((0, 1)), ..annotation("Text", Some("Agreed"), false) }; + assert_eq!(hover_tip(&reply, &prefs), None); + let bare = Annotation { author: Some("Ana".into()), ..annotation("Highlight", None, false) }; + assert_eq!(hover_tip(&bare, &prefs).as_deref(), Some("Ana")); + let hide = CommentPrefs { hide_empty_highlight_hover: true, ..CommentPrefs::default() }; + assert_eq!(hover_tip(&bare, &hide), None); + assert_eq!(hover_tip(&annotation("Highlight", Some("Check this"), false), &hide).as_deref(), Some("Highlight\nCheck this")); + } + fn near(a: Option<([f32; 2], f32)>, b: ([f32; 2], f32)) -> bool { a.is_some_and(|([x, y], s)| (x - b.0[0]).abs() < 1e-3 && (y - b.0[1]).abs() < 1e-3 && (s - b.1).abs() < 1e-3) } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 0da3b235c8..56f5ac2729 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::widgets; @@ -347,7 +348,6 @@ pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: /// used the input. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( - ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, @@ -398,13 +398,8 @@ pub(crate) fn page_input( .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); - // Cursors only where the page shows: the mode bar and side panels over a page scrolled under - // them keep their own (#2755). - let shows = resp.contains_pointer(); if adding_text && hit.is_none() { - if shows { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); if resp.clicked() { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { @@ -429,10 +424,10 @@ pub(crate) fn page_input( } return consumed; } - if let Some((_, left, top)) = corner.filter(|_| shows) { - ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } else if hit.is_some() && shows { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + if let Some((_, left, top)) = corner { + view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + } else if hit.is_some() { + view.cursor.claim(Layer::Placed, egui::CursorIcon::Move); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 1f1e1aa131..9e0d69b26e 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; /// A crop rectangle being dragged: (page, start on screen). @@ -31,7 +32,7 @@ pub(crate) fn page_input( ) -> bool { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); + view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); } if !allowed { return false; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 4f5630c0b9..1f0e646094 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -617,7 +618,9 @@ fn crop_input( painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text); - if let Some(p) = hover_pos { + // Cropping takes over its page's cursor, and the buttons' wherever they are; other pages + // keep theirs. + if let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p) || done.contains(*p) || cancel.contains(*p)) { let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { match g { 0 | 3 => egui::CursorIcon::ResizeNwSe, @@ -632,7 +635,7 @@ fn crop_input( } else { egui::CursorIcon::Default }; - ui.ctx().set_cursor_icon(icon); + view.cursor.claim(Layer::Editor, icon); } let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)); let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); @@ -911,7 +914,7 @@ pub(crate) fn image_input( handles(b); } if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { - ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + view.cursor.claim(Layer::Handle, if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } // The rotate handle: a knob above the top edge, joined to it by a short stem. let (knob, stem) = rotate_knob(b, ui.clip_rect()); @@ -919,7 +922,7 @@ pub(crate) fn image_input( painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } if allow_interaction @@ -959,7 +962,7 @@ pub(crate) fn image_input( let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; @@ -1032,11 +1035,14 @@ pub(crate) fn image_input( Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); } } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); + view.cursor.claim( + Layer::Drag, + match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }, + ); return true; } if allow_interaction @@ -1074,12 +1080,13 @@ pub(crate) fn image_input( } let Some(b) = boxes.get(hit).copied() else { return false }; let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0); - ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) { - (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe, - (true, Some(_)) => egui::CursorIcon::ResizeNeSw, - (true, None) => egui::CursorIcon::Move, - _ => egui::CursorIcon::PointingHand, - }); + match (selected == Some(hit), corner) { + (true, Some(0 | 3)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNwSe), + (true, Some(_)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNeSw), + (true, None) => view.cursor.claim(Layer::Placed, egui::CursorIcon::Move), + // A click selects it. + _ => view.cursor.claim(Layer::Placed, egui::CursorIcon::PointingHand), + } if resp.clicked() || resp.secondary_clicked() { view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None }); } @@ -1288,7 +1295,7 @@ pub(crate) fn page_input( if resizing { draw_handles(preview); } - ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); + view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); if resp.drag_stopped() || !ptr.any_down { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); @@ -1337,17 +1344,13 @@ pub(crate) fn page_input( draw_handles(boxes[hit]); } // A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it - // for typing); an edge rewraps it. The open one takes the text cursor. Only where the page - // shows, so the mode bar and side panels over a paragraph scrolled under them keep their own - // cursor (#2755). - if resp.contains_pointer() { - ui.ctx().set_cursor_icon(if on_edge { - egui::CursorIcon::ResizeHorizontal - } else if active.is_none() { - egui::CursorIcon::Move - } else { - egui::CursorIcon::Text - }); + // for typing); an edge rewraps it. The open one takes the text cursor. + if on_edge { + view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeHorizontal); + } else if active.is_none() { + view.cursor.claim(Layer::Text, egui::CursorIcon::Move); + } else { + view.cursor.claim(if active == Some(hit) { Layer::Editor } else { Layer::Text }, egui::CursorIcon::Text); } if resp.clicked() { view.image_selection = None; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index aab071b508..0861cd3cdd 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; @@ -651,10 +652,10 @@ pub(crate) fn page_input( if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) { let tex = image_texture(ui, image, preview); xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE); - ui.ctx().set_cursor_icon(egui::CursorIcon::None); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::None); } } else { - ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); + view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } if !resp.clicked() { return None; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 28494160c5..e0d51a3da2 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -14,6 +14,7 @@ use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, fi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; @@ -151,20 +152,7 @@ fn toggle_edit(f: &FormField, w: &FormWidget) -> Option { Some(Edit::SetFieldValue { name: f.name.clone(), value }) } -/// Where the pointer is relative to one page's form widgets. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum FieldPointer { - /// Not over a field. - Away, - /// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see - /// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set - /// on printed text, is a button, not text to select. - Over, - /// The field owns the press or click. - Owned, -} - -/// Clicks on form widgets of one page. +/// Clicks on form widgets of one page. `true` when a field owns the press or click. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, @@ -176,8 +164,8 @@ pub(crate) fn page_input( form: &[FormField], allowed: bool, view: &mut DocView, -) -> FieldPointer { - let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away }; +) -> bool { + let Some(p) = ptr.within(xf.rect) else { return false }; let hit = form.iter().find_map(|f| { f.widgets .iter() @@ -185,7 +173,7 @@ pub(crate) fn page_input( .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) .map(|(i, w)| (f, i, w)) }); - let Some((f, wi, w)) = hit else { return FieldPointer::Away }; + let Some((f, wi, w)) = hit else { return false }; // The field's tooltip (`/TU`), as Acrobat shows it on hover (#3854). ISO 32000-2 Table 226 // makes it the name the user interface identifies the field by. if let Some(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) { @@ -196,15 +184,16 @@ pub(crate) fn page_input( let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); // A radio button of a group can be read-only on its own. let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature); - // Only where the page shows: the mode bar and side panels over a field scrolled under them - // keep their own cursor (#2755). - if resp.contains_pointer() { - ui.ctx().set_cursor_icon(match (usable, f.kind) { + // Over the page's text too: a check box drawn with a glyph, or set on printed text, is a + // button, not text to select. + view.cursor.claim( + Layer::Field, + match (usable, f.kind) { (false, _) => egui::CursorIcon::NotAllowed, (true, FormFieldKind::Text) => egui::CursorIcon::Text, _ => egui::CursorIcon::PointingHand, - }); - } + }, + ); // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); @@ -214,11 +203,11 @@ pub(crate) fn page_input( view.forms.pressed = true; } if !resp.clicked() { - return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over }; + return resp.is_pointer_button_down_on(); } if !allowed { view.forms.notice = Some(FormNotice::Security); - return FieldPointer::Owned; + return true; } match f.kind { _ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())), @@ -289,7 +278,7 @@ pub(crate) fn page_input( } } } - FieldPointer::Owned + true } /// The edit the focused field's draft makes, if it differs from the field's value. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..f909698238 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -104,6 +104,7 @@ pub mod icons; pub mod last_session; pub mod last_view; pub mod motion; +mod page_cursor; mod page_pointer; mod page_selection_ui; mod pageboxes; diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index f7768912dd..012a95f95f 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -86,7 +87,10 @@ pub(crate) fn page_input( } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - ui.ctx().set_cursor_icon(if hit.is_some() { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Crosshair }); + match hit { + Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), + None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), + } if resp.secondary_clicked() && let Some(l) = hit { diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 79670ad304..17e3ced693 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -1,5 +1,5 @@ //! Measure objects: page-space clicks, live readings and persistent drawing scales. -use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, widgets}; +use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets}; use egui::{Color32, Pos2, Stroke}; use pdfcraft_engine::{ Document, Edit, Style, @@ -200,7 +200,7 @@ pub(crate) fn page_input( let mut hover = None; let mut snap = None; if let Some(pos) = pos { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); let (vx, vy) = xf.screen_to_view(pos); let [x, y] = info.view_to_user(vx, vy); let mut point = [f64::from(x), f64::from(y)]; diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index d495116c6c..8d4a1137e6 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -255,7 +256,7 @@ fn input( } else { s.gesture = Some(gesture); } - ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); + view.cursor.claim(Layer::Drag, if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { @@ -265,7 +266,7 @@ fn input( if s.page == Some(page) && !s.selected.is_empty() { let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); if union.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + view.cursor.claim(Layer::Placed, egui::CursorIcon::Move); } return true; } diff --git a/crates/ui-egui/src/page_cursor.rs b/crates/ui-egui/src/page_cursor.rs new file mode 100644 index 0000000000..9abfb7e994 --- /dev/null +++ b/crates/ui-egui/src/page_cursor.rs @@ -0,0 +1,332 @@ +//! The cursor the pages show, and the tip beside it, set once a frame. +//! +//! A page module claims the cursor for what it has under the pointer, on a [`Layer`], and never +//! sets egui's cursor itself: where each module set it, the last write of a frame won, so the +//! order of the per-page chain decided the cursor and not what a press there does. A link's hand +//! showed over every tool though only the Select tool follows links, and the I-beam of the text +//! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the +//! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is +//! that claim's own (a link's target, so only where a press follows the link), or else what lies +//! under the pointer says (a comment, or a signature's validation badge), with any tool, as +//! Acrobat shows a comment's tooltip. A test in `page_pointer.rs` keeps page modules from setting +//! the cursor. + +use egui::CursorIcon; + +use crate::QuickTool; + +/// What a claim is for, from the bottom up: a claim on a higher layer wins. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Layer { + /// The page under the active tool: the crosshair it places or draws with, its I-beam or hand. + ToolArea, + /// The page's text, to select, and Edit text's paragraphs. + Text, + /// Links. + Link, + /// Comments: notes, shapes and markup. + Comment, + /// Form fields. + Field, + /// What a drag moves whole: signatures and Fill & Sign's marks, added text and images, + /// selected objects, pictures in Edit text. + Placed, + /// The grips of the selected item: resize, rewrap and crop handles, paragraph edges. + Handle, + /// An open editor: a paragraph being typed in, a picture being cropped. + Editor, + /// A drag in progress: what is dragged keeps its cursor until the release. + Drag, +} + +/// Every layer, bottom first. +const LAYERS: [Layer; 9] = + [Layer::ToolArea, Layer::Text, Layer::Link, Layer::Comment, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag]; + +impl Layer { + /// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims + /// count. Only the Select tool follows links (the Link tool edits them) and picks up + /// comments: every other tool draws, places or selects text over them. The other layers are + /// claimed only by the modules of the tools that act on them, so they count for every tool. + pub(crate) fn reached_by(self, tool: QuickTool) -> bool { + match self { + Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link), + Layer::Comment => tool == QuickTool::Select, + Layer::ToolArea | Layer::Text | Layer::Field | Layer::Placed | Layer::Handle | Layer::Editor | Layer::Drag => true, + } + } +} + +/// A claim on the cursor: the icon, and the tip that goes with it (a link's target). +#[derive(Clone, Debug, PartialEq)] +struct Claim { + icon: CursorIcon, + tip: Option, +} + +/// What a description tells about, from the least up: a higher one shows over a lower one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Subject { + /// A signature's validation badge: viewer chrome over the signer's own artwork. + SignatureBadge, + /// A comment: who wrote it, and what it says. + Comment, +} + +/// What the page modules claim for the cursor this frame. `canvas::document_area` sets the +/// cursor from it once, after the pages ([`PageCursor::resolve`]). +#[derive(Debug, Default)] +pub(crate) struct PageCursor { + /// The last claim on each layer, bottom layer first. + claims: [Option; LAYERS.len()], + /// What lies under the pointer says ([`PageCursor::describe`]). + description: Option<(Subject, String)>, +} + +impl PageCursor { + /// Shows `icon` while the pointer is over what lies on `layer`, unless the active tool + /// reaches a higher layer that is claimed too. + pub(crate) fn claim(&mut self, layer: Layer, icon: CursorIcon) { + self.claim_with_tip(layer, icon, None); + } + + /// As [`Self::claim`], with `tip` beside the pointer where the claim wins. + pub(crate) fn claim_with_tip(&mut self, layer: Layer, icon: CursorIcon, tip: Option) { + if let Some(slot) = self.claims.get_mut(layer as usize) { + *slot = Some(Claim { icon, tip }); + } + } + + /// Says what lies under the pointer without claiming the cursor: the tip shows with any tool, + /// beside that tool's cursor, as Acrobat shows a comment's tooltip, unless the winning claim + /// has a tip of its own. The description of the highest subject stands, and the last of + /// equals (the topmost of overlapping badges). + pub(crate) fn describe(&mut self, subject: Subject, text: String) { + if self.description.as_ref().is_none_or(|(held, _)| *held <= subject) { + self.description = Some((subject, text)); + } + } + + /// The claim on the highest layer `tool` reaches. + fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { + LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) + } + + /// Sets the cursor, once, to the icon of the claim on the highest layer `tool` reaches, and + /// returns the tip: that claim's own, or else the description. With no such claim the cursor + /// is left as it is. The claims are spent: the next frame starts with none. + pub(crate) fn resolve(&mut self, ctx: &egui::Context, tool: QuickTool) -> Option { + let PageCursor { claims, description } = std::mem::take(self); + let winner = Self::winner(claims, tool); + if let Some(w) = &winner { + ctx.set_cursor_icon(w.icon); + } + winner.and_then(|w| w.tip).or(description.map(|(_, text)| text)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::comments::CommentTool; + use crate::fill_sign::FillTool; + use crate::prepare::FieldTool; + + /// One of each tool. The match stops compiling when a tool is added, so that it gets a row + /// in [`Layer::reached_by`]'s tests. + fn tools() -> Vec { + let tools = vec![ + QuickTool::Select, + QuickTool::Hand, + QuickTool::Comment(CommentTool::Rectangle), + QuickTool::Comment(CommentTool::Highlight), + QuickTool::Measure(crate::measure_ui::Tool::Distance), + QuickTool::Crop, + QuickTool::EnhanceCamera, + QuickTool::Fill(FillTool::Signature), + QuickTool::Field(FieldTool::Text), + QuickTool::Redact, + QuickTool::AddText, + QuickTool::EditText, + QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved), + QuickTool::CustomStamp(0), + QuickTool::Link, + QuickTool::SignArea { certify: false }, + QuickTool::MarqueeZoom, + QuickTool::Snapshot, + QuickTool::ColumnSelect, + ]; + for tool in &tools { + match tool { + QuickTool::Select + | QuickTool::Hand + | QuickTool::Comment(_) + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::Redact + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot + | QuickTool::ColumnSelect => {} + } + } + tools + } + + #[test] + fn layers_are_listed_bottom_first_in_their_order() { + for (i, layer) in LAYERS.into_iter().enumerate() { + assert_eq!(layer as usize, i, "{layer:?}"); + } + assert!(LAYERS.windows(2).all(|w| w[0] < w[1])); + } + + #[test] + fn links_count_for_the_select_and_link_tools_only() { + for tool in tools() { + assert_eq!(Layer::Link.reached_by(tool), matches!(tool, QuickTool::Select | QuickTool::Link), "{tool:?}"); + } + } + + #[test] + fn comments_count_for_the_select_tool_only() { + for tool in tools() { + assert_eq!(Layer::Comment.reached_by(tool), tool == QuickTool::Select, "{tool:?}"); + } + } + + #[test] + fn the_other_layers_count_for_every_tool() { + for tool in tools() { + for layer in [Layer::ToolArea, Layer::Text, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag] { + assert!(layer.reached_by(tool), "{layer:?} for {tool:?}"); + } + } + } + + fn claims(claims: &[(Layer, CursorIcon, Option<&str>)]) -> PageCursor { + let mut cursor = PageCursor::default(); + for (layer, icon, tip) in claims { + cursor.claim_with_tip(*layer, *icon, tip.map(str::to_string)); + } + cursor + } + + fn winner(cursor: PageCursor, tool: QuickTool) -> Option { + PageCursor::winner(cursor.claims, tool) + } + + #[test] + fn the_highest_layer_the_tool_reaches_wins_with_its_tip() { + let on_text_under_a_link = [(Layer::Text, CursorIcon::Text, None), (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"))]; + let select = winner(claims(&on_text_under_a_link), QuickTool::Select); + assert_eq!(select, Some(Claim { icon: CursorIcon::PointingHand, tip: Some("https://example.org/".into()) })); + // A markup tool selects the text there, and the link's tip goes with its hand. + let highlight = winner(claims(&on_text_under_a_link), QuickTool::Comment(CommentTool::Highlight)); + assert_eq!(highlight, Some(Claim { icon: CursorIcon::Text, tip: None })); + // A tool reaching nothing claimed leaves the cursor alone. + assert_eq!(winner(claims(&[(Layer::Link, CursorIcon::PointingHand, None)]), QuickTool::Hand), None); + } + + #[test] + fn a_higher_claim_without_a_tip_hides_a_lower_ones_tip() { + let field_over_a_link = [(Layer::Link, CursorIcon::PointingHand, Some("Go to page 2")), (Layer::Field, CursorIcon::Text, None)]; + assert_eq!(winner(claims(&field_over_a_link), QuickTool::Select), Some(Claim { icon: CursorIcon::Text, tip: None })); + } + + #[test] + fn the_last_claim_on_a_layer_wins() { + let two_links = [(Layer::Link, CursorIcon::PointingHand, Some("first")), (Layer::Link, CursorIcon::PointingHand, Some("second"))]; + assert_eq!(winner(claims(&two_links), QuickTool::Select).and_then(|c| c.tip).as_deref(), Some("second")); + } + + /// Runs one frame that sets the cursor to `before`, if any, then resolves `claims` for + /// `tool`. Returns the frame's cursor and the tip. + fn frame(ctx: &egui::Context, before: Option, claims: &mut PageCursor, tool: QuickTool) -> (CursorIcon, Option) { + let mut tip = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + if let Some(icon) = before { + ui.ctx().set_cursor_icon(icon); + } + tip = claims.resolve(ui.ctx(), tool); + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + (output.platform_output.cursor_icon, tip) + } + + #[test] + fn resolve_sets_the_winners_cursor_and_spends_the_claims() { + let ctx = egui::Context::default(); + let on_a_link = || claims(&[(Layer::ToolArea, CursorIcon::Crosshair, None), (Layer::Link, CursorIcon::PointingHand, Some("tip"))]); + assert_eq!(frame(&ctx, None, &mut on_a_link(), QuickTool::Select), (CursorIcon::PointingHand, Some("tip".into()))); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(frame(&ctx, None, &mut on_a_link(), stamp), (CursorIcon::Crosshair, None), "the stamp's crosshair, and no link tip"); + // Nothing the tool reaches: the cursor stays as the frame set it. + let mut only_a_link = claims(&[(Layer::Link, CursorIcon::PointingHand, Some("tip"))]); + only_a_link.describe(Subject::Comment, "Ana".into()); + assert_eq!(frame(&ctx, Some(CursorIcon::Help), &mut only_a_link, QuickTool::Hand), (CursorIcon::Help, Some("Ana".into()))); + // Resolving spends the claims, reached or not, and the description. + assert_eq!(only_a_link.description, None); + assert_eq!(winner(only_a_link, QuickTool::Select), None); + } + + #[test] + fn a_description_shows_with_any_tool_unless_the_winner_has_a_tip_of_its_own() { + let ctx = egui::Context::default(); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + let link = (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/")); + let described = |claimed: &[(Layer, CursorIcon, Option<&str>)]| { + let mut cursor = claims(claimed); + cursor.describe(Subject::Comment, "Ana\nCheck this".into()); + cursor + }; + let comment = Some("Ana\nCheck this".to_string()); + // A comment under the stamp's crosshair: the tool's cursor, and what the comment says. + let crosshair = (Layer::ToolArea, CursorIcon::Crosshair, None); + assert_eq!(frame(&ctx, None, &mut described(&[crosshair]), stamp), (CursorIcon::Crosshair, comment.clone())); + // A link the tool follows speaks for itself; one it doesn't keeps its tip to itself. + assert_eq!( + frame(&ctx, None, &mut described(&[crosshair, link]), QuickTool::Select), + (CursorIcon::PointingHand, Some("https://example.org/".into())) + ); + assert_eq!(frame(&ctx, None, &mut described(&[crosshair, link]), stamp), (CursorIcon::Crosshair, comment.clone())); + // A winner without a tip of its own, such as the Select tool's comment claim. + let picked_up = (Layer::Comment, CursorIcon::Move, None); + assert_eq!(frame(&ctx, None, &mut described(&[link, picked_up]), QuickTool::Select), (CursorIcon::Move, comment)); + } + + #[test] + fn a_comment_says_more_than_a_signature_badge_and_the_last_badge_stands() { + let badge_then_comment = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor.describe(Subject::Comment, "Ana".into()); + cursor + }; + let comment_then_badges = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::Comment, "Ana".into()); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor + }; + let two_badges = { + let mut cursor = PageCursor::default(); + cursor.describe(Subject::SignatureBadge, "Signature is valid".into()); + cursor.describe(Subject::SignatureBadge, "Signature is invalid".into()); + cursor + }; + let said = |cursor: PageCursor| cursor.description.map(|(_, text)| text); + assert_eq!(said(badge_then_comment).as_deref(), Some("Ana")); + assert_eq!(said(comment_then_badges).as_deref(), Some("Ana"), "whichever came first"); + assert_eq!(said(two_badges).as_deref(), Some("Signature is invalid"), "the badge painted last is on top"); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 2635693676..5d7cf72e1e 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::comments; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; @@ -344,7 +345,7 @@ pub(crate) fn page_input( return out; } if let Some(tool) = tool { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); if resp.drag_started() || resp.clicked() { let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); @@ -397,9 +398,9 @@ pub(crate) fn page_input( f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) }); if let Some((_, left, top)) = corner { - ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } else if hit.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + view.cursor.claim(Layer::Field, egui::CursorIcon::Move); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 6a5bee7ea4..52cadc2aa2 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS, use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::{PdfCraftApp, widgets}; @@ -237,7 +238,6 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// the gesture is a box. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( - ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, @@ -263,7 +263,7 @@ pub(crate) fn page_input( } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; if !over_text(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() { let origin = ptr.origin.unwrap_or(p); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 8f6b2c4a0c..4df02cbf41 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -163,7 +164,7 @@ pub(crate) fn page_input( ) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index e0315763fa..8e530ac58e 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; use crate::canvas::{DocView, PageXform}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::{PdfCraftApp, QuickTool}; @@ -16,7 +17,7 @@ pub type Marquee = (usize, Rect, [f32; 4]); pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } if resp.drag_started() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..1b5181ff60 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3974,12 +3974,14 @@ fn dragging_the_rotate_knob_turns_a_page_image() { let to = egui::pos2(c.x + 100.0, c.y); h.hover_at(knob); h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "the knob offers to turn the picture"); h.drag_at(knob); h.run_steps(1); for k in 1..=4 { h.hover_at(knob + (to - knob) * (k as f32 / 4.0)); h.run_steps(1); } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "and holds it while it turns"); h.drop_at(to); h.run_steps(4); let doc = h.state().session.get(id).unwrap(); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index f2b6c0e907..74120834bc 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -3,12 +3,14 @@ //! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's //! cursor, tips or presses through. //! -//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor -//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4). +//! The ignored tests pin live bugs that later steps of the page pointer router fix: one hit test +//! (step 3), and press-time capture with cancel (step 4). use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; +use pdfcraft_ui_egui::fill_sign::FillTool; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -76,6 +78,18 @@ fn comment_on_text_pdf() -> Vec { ]) } +/// A 300×400 page with two rectangle comments overlapping at x 100..150, y 200..250: "Bottom +/// note" first in `/Annots`, then "Top note" over it. +fn stacked_comments_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [50 150 150 250] /C [1 0 0] /F 4 /T (Ana) /Contents (Bottom note) >>", + "<< /Type /Annot /Subtype /Square /Rect [100 200 200 300] /C [0 0 1] /F 4 /T (Ben) /Contents (Top note) >>", + ]) +} + /// A 300×400 page with one line of text at x 40, y 300. fn text_pdf() -> Vec { let text = stream(LINE); @@ -173,6 +187,12 @@ fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone() } +/// Whether the page's hover tip shows and says `text`. +fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool { + let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).filter(|_| tip_shown(h)) else { return false }; + h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center())) +} + /// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from /// worker threads), and returns the last cursor seen. fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { @@ -191,7 +211,6 @@ fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl F // The cursor promises one thing and a press does another. #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn stamp_tool_over_a_link_shows_its_crosshair() { let mut h = open(link_pdf(), "150", false); stamp_tool(&mut h); @@ -205,7 +224,6 @@ fn stamp_tool_over_a_link_shows_its_crosshair() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn rectangle_tool_over_a_link_shows_its_crosshair() { let mut h = open(link_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); @@ -218,7 +236,6 @@ fn rectangle_tool_over_a_link_shows_its_crosshair() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn edit_text_over_a_linked_word_offers_the_move_arrow() { let mut h = open(linked_text_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::EditText; @@ -242,7 +259,6 @@ fn edit_text_over_a_linked_word_offers_the_move_arrow() { } #[test] -#[ignore = "step 2 of the page pointer router: one cursor writer"] fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { let mut h = open(comment_on_text_pdf(), "150", false); // Inside the rectangle where there is no text, and inside it over a word. @@ -259,6 +275,71 @@ fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too"); } +#[test] +fn a_links_tip_shows_only_with_its_hand() { + let mut h = open(link_pdf(), "150", false); + let on_link = at(&h, 150.0, 325.0); + let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, nudge: f32| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + // A new position each time, so the hover is fresh. + let cursor = cursor_at(h, on_link + egui::vec2(nudge, 0.0)); + h.run_steps(2); + (cursor, tip_says(h, "https://example.org/")) + }; + use egui::CursorIcon::{Crosshair, PointingHand}; + assert_eq!(hover(&mut h, QuickTool::Select, 0.0), (PointingHand, true), "the Select tool follows the link, and the tip says where to"); + assert_eq!(hover(&mut h, QuickTool::Link, 1.0), (PointingHand, true), "the Link tool edits it"); + let stamp = QuickTool::Stamp(StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, 2.0), (Crosshair, false), "a click places a stamp, so neither hand nor tip offers the link"); + let rectangle = QuickTool::Comment(CommentTool::Rectangle); + assert_eq!(hover(&mut h, rectangle, 3.0), (Crosshair, false), "a drag draws a rectangle"); +} + +/// Over two comments the tip names the one drawn on top (#3620), and a click should select that +/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in +/// inspection's order, which is by top edge, and selects the one underneath. +#[test] +#[ignore = "step 3 of the page pointer router: one hit test"] +fn a_click_on_stacked_comments_selects_the_one_drawn_on_top() { + let mut h = open(stacked_comments_pdf(), "150", false); + let overlap = at(&h, 125.0, 225.0); + assert_eq!(cursor_at(&mut h, overlap), egui::CursorIcon::Move); + h.run_steps(2); + assert!(tip_says(&h, "Top note") && !tip_says(&h, "Bottom note"), "the tip names the comment drawn on top"); + mouse_click(&mut h, overlap); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "a click selects it too"); +} + +#[test] +fn a_comments_tip_shows_with_every_tool_beside_its_cursor() { + let mut h = open(comment_on_text_pdf(), "150", false); + // Inside the rectangle comment by "Reviewer": where there is no text, and over a word. + let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0)); + let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + let cursor = wait_for_cursor(h, p, |c| c == want); + h.run_steps(2); + (cursor, tip_says(h, "Reviewer")) + }; + use egui::CursorIcon::{Crosshair, Move, Text}; + // A new position each time, so the hover is fresh. + let nudge = |n: f32| blank + egui::vec2(n, 0.0); + assert_eq!(hover(&mut h, QuickTool::Select, blank, Move), (Move, true), "the Select tool picks the comment up"); + let stamp = QuickTool::Stamp(StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, nudge(1.0), Crosshair), (Crosshair, true), "a stamp keeps its crosshair, and the comment still says whose it is"); + let rectangle = QuickTool::Comment(CommentTool::Rectangle); + assert_eq!(hover(&mut h, rectangle, nudge(2.0), Crosshair), (Crosshair, true), "so does a drawing tool"); + let highlight = QuickTool::Comment(CommentTool::Highlight); + assert_eq!(hover(&mut h, highlight, on_text, Text), (Text, true), "and the highlighter its I-beam"); + // Not while drawing freehand, where it would cover the next stroke (#429), nor with Fill & Sign. + let ink = QuickTool::Comment(CommentTool::Ink); + assert_eq!(hover(&mut h, ink, nudge(3.0), Crosshair), (Crosshair, false), "freehand"); + let fill = QuickTool::Fill(FillTool::Check); + assert_eq!(hover(&mut h, fill, nudge(4.0), Crosshair), (Crosshair, false), "Fill & Sign"); +} + /// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points /// left of the edge. Returns the cursor shown at the press point and the line count after. fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) { diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..b37f134a1a 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -331,13 +331,25 @@ fn clicking_an_empty_signature_field_signs_it() { let dir = dir().join("field"); std::fs::create_dir_all(&dir).unwrap(); let mut h = harness(dir.clone()); + sign_the_empty_field(&mut h); + let s = h.state(); + let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); + assert!(sig.signed && sig.visible); +} + +/// The centre of `WITH_FIELD`'s signature field, opened as the second document. +fn field_centre(h: &Harness<'static, PdfCraftApp>) -> Pos2 { + let r = h.state().views[1].page_screen_rect(0).expect("on screen"); + pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height()) +} + +/// Opens `WITH_FIELD` as the second document and signs its empty field with the test ID, by +/// clicking the field. +fn sign_the_empty_field(h: &mut Harness<'static, PdfCraftApp>) { h.state_mut().open_bytes("form.pdf", None, WITH_FIELD.to_vec()).unwrap(); h.state_mut().active = Some(1); h.run_steps(6); - let p = { - let r = h.state().views[1].page_screen_rect(0).expect("on screen"); - pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height()) - }; + let p = field_centre(h); h.hover_at(p); h.run_steps(1); h.drag_at(p); @@ -363,9 +375,39 @@ fn clicking_an_empty_signature_field_signs_it() { h.run_steps(2); h.get_by_label("Sign").click(); h.run_steps(6); - let s = h.state(); - let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); - assert!(sig.signed && sig.visible); +} + +/// Whether the page's hover tip shows and says `text`. +fn tip_says(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover")); + let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover")).filter(|_| m.areas().is_visible(&layer))) else { return false }; + h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center())) +} + +/// A signed field's validation badge is the viewer's own mark, not part of the page: its verdict +/// is the tip with any tool, beside that tool's cursor. +#[test] +fn a_signature_badge_says_its_verdict_with_any_tool() { + let dir = dir().join("badge"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + sign_the_empty_field(&mut h); + let verdict = { + let s = h.state(); + let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); + sig.summary() + }; + let p = field_centre(&h); + let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, at: Pos2| { + h.state_mut().quick_tool = tool; + h.run_steps(4); + h.hover_at(at); + h.run_steps(4); + (h.output().platform_output.cursor_icon, tip_says(h, verdict)) + }; + assert!(hover(&mut h, QuickTool::Select, p).1, "the Select tool shows the verdict"); + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..45a107ab94 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1202,8 +1202,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] -notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." +evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/pointer.rs::a_comments_tip_shows_with_every_tool_beside_its_cursor", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] +notes = "A popup with the author and comment text, as Acrobat shows it: with every tool, beside that tool's own cursor (Acrobat does the same with Select, Hand, Add comment, Highlight, Rectangle and Stamp); not with Fill & Sign, while drawing freehand (#429) or while a comment is moved, resized or drawn. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Still missing: Acrobat's hover outline on a highlight with every tool, and its hit on an unfilled shape's border only. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] id = "view.page-size-hover" From 61f92a1d0c9364283a90349ebfa615e55dcb880a Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 19 Oct 2026 19:34:27 +0200 Subject: [PATCH 14/75] test(ui): page modules claim the cursor and never set it A sibling of page_modules_read_the_pointer_only_through_page_pointer in page_pointer.rs, sharing its scan of the page modules (now `misuses`, which both tests call): it fails on set_cursor_icon, a `.cursor_icon = ` write to egui's output, on_hover_cursor and on_hover_and_drag_cursor in a page module, naming the file and line and pointing to the claims in src/page_cursor.rs. Only the resolver, which is not a page module, sets the pages' cursor. NOT_PAGE_CURSOR allows the three writers that are not the pages' cursor: the signature dialog's Scan tab (fill_sign::scan_tab), the paragraph editor's frame (edit_text_ui::overlay, an Area of its own drawn after the pages' cursor is set) and the Calculation order panel, whose rows are dragged to reorder them (calc_order_ui::panel). An entry that sets no cursor any more fails too. Seen failing with a set_cursor_icon put back into canvas.rs's link loop, an output_mut cursor write in forms_ui, an on_hover_cursor in comments and the Scan tab's write removed: one message each. The pointer guard still fails on a hover_pos() read put into crop.rs. Rebased onto main's newer page modules, it named one write before the panel's entry was added, calc_order_ui.rs's Grabbing: main's other new cursors are claims now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/page_pointer.rs | 80 ++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index a6a318c1fd..c6baf70908 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -346,22 +346,33 @@ mod tests { rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) } - /// Whether `line` calls `read`, and not a longer name that ends in it. - fn reads(line: &str, read: &str) -> bool { - line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + /// Ways to set egui's cursor. + const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; + + /// Functions in the page modules that set a cursor other than the pages': (file, function, + /// why). + const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [ + ("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"), + ("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"), + ("calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"), + ]; + + /// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its + /// `.`, is never part of a longer name). + fn uses(line: &str, name: &str) -> bool { + line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } - /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is - /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back - /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. - #[test] - fn page_modules_read_the_pointer_only_through_page_pointer() { + /// Each use of one of `names` in the page modules' code (their tests left out) outside the + /// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that + /// uses none of them any more, as "src/file: fn function unused". + fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut problems = Vec::new(); - let mut needed = vec![false; NOT_PAGE_INPUT.len()]; + let mut needed = vec![false; allowed.len()]; for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // Tests drive egui's input themselves. + // Tests drive egui's input and read its output themselves. let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); @@ -376,24 +387,36 @@ mod tests { if line.trim_start().starts_with("//") { continue; } - for read in RAW_READS.into_iter().filter(|read| reads(line, read)) { - match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) { + for name in names.iter().filter(|name| uses(line, name)) { + match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) { Some(k) => needed[k] = true, - None => problems.push(format!( - "src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \ - (src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \ - lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.", - n + 1 - )), + None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)), } } } } - for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) { + for ((file, name, _), needed) in allowed.iter().zip(needed) { if !needed { - problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT.")); + problems.push(format!("src/{file}: fn {name} {unused}")); } } + problems + } + + /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is + /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back + /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. + #[test] + fn page_modules_read_the_pointer_only_through_page_pointer() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut problems = misuses( + &RAW_READS, + &NOT_PAGE_INPUT, + "reads egui's pointer state in a page module. Read `PagePointer` (src/page_pointer.rs) instead: it knows \ + what is drawn over the pages and where a touch lifted. Not page input? Add the function to NOT_PAGE_INPUT \ + there, with why.", + "reads no pointer state any more: remove it from NOT_PAGE_INPUT.", + ); // A module that takes the page pointer is a page module, so it is checked too. let mut dirs = vec![src.clone()]; while let Some(dir) = dirs.pop() { @@ -416,4 +439,21 @@ mod tests { } assert!(problems.is_empty(), "{}", problems.join("\n")); } + + /// Page modules claim the cursor and never set it. Where each set it, the last write of a + /// frame won, so the order of the per-page chain decided the cursor and not what a press + /// does: a link's hand over every tool, the I-beam over a comment that a drag moves. + #[test] + fn page_modules_claim_the_cursor_and_never_set_it() { + let problems = misuses( + &CURSOR_WRITES, + &NOT_PAGE_CURSOR, + "sets the cursor in a page module, where the last write of a frame would win. Claim it instead, on the \ + layer of what is under the pointer (`view.cursor.claim`, src/page_cursor.rs): `document_area` sets it \ + once, from the claim on the highest layer the active tool reaches. Not the pages' cursor? Add the \ + function to NOT_PAGE_CURSOR in src/page_pointer.rs, with why.", + "sets no cursor any more: remove it from NOT_PAGE_CURSOR.", + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); + } } From 5815dbfb794bec4dc5bb7dfa449cd36801171eff Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 19 Oct 2026 19:34:27 +0200 Subject: [PATCH 15/75] 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 16/75] 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 17/75] 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 18/75] 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 19/75] 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 20/75] 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 21/75] 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 22/75] 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 23/75] 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 24/75] 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 25/75] 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 26/75] 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 27/75] 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 a9ddf79a19bae076d8c10d4f0c9e7784e99ce094 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 08:05:28 -0400 Subject: [PATCH 28/75] M6: a radio button renamed to a radio group's name joins that group (#4949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a radio button to an existing radio group's name was refused as a duplicate, so buttons placed separately could never become one group. set_props now moves the renamed field's buttons into the named group (ISO 32000-2 §12.7.5.2.4: a group is one field whose widgets are its buttons). A clashing export value is renamed to the first free ChoiceN in /AP /N, /D and /R; a moved button is on only if its value is the group's value; merged field+widgets are handled on both sides (the target split via split_merged, now shared with duplicate_field). Refused when either group has /Opt, has no button, or the target is locked. Adapted from pdfcer (MIT, same author) 79870b56 and 116f48d3. 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/author.rs | 1 + crates/forms/src/author/edit.rs | 23 ++++- crates/forms/src/author/join.rs | 114 +++++++++++++++++++++ crates/forms/src/author/lifecycle.rs | 72 ++++++------- crates/forms/src/tests.rs | 124 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 322 insertions(+), 39 deletions(-) create mode 100644 crates/forms/src/author/join.rs diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..04b7241798 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4004,6 +4004,29 @@ fn preparing_a_form_through_tools() { assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } +#[test] +fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() { + // #4949: two buttons placed separately are two groups; naming one after the other makes + // them one group, and a clashing export value is renamed instead of shared. + let dir = workdir("radio-join"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" })); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } })); + let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" })); + assert_eq!(r["field"], "size"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let f = fields["fields"].as_array().unwrap(); + assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["size"], "one group"); + assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value"); + assert_eq!(f[0]["value"], "S", "the group keeps its selection"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1"); +} + #[test] fn rotating_a_field_through_tools() { let dir = workdir("rotate-field"); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index ce89fad806..5de3e9b3e7 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -11,6 +11,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; mod edit; +mod join; mod lifecycle; mod look; mod tree; diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4fc..1b8caee92a 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,3 +1,4 @@ +use super::join::{check_join, join_radio_group}; 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::*; @@ -71,6 +72,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result own, _ => n, }; - if n.contains('.') { + let full = match f.name.rsplit_once('.') { + Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"), + _ => n.to_string(), + }; + let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full); + if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) { + check_join(doc, &f, group)?; + join = Some(group.clone()); + new_name = full; + } else 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)) { @@ -385,7 +398,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result bool { + doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) +} + +/// Refuse what [`join_radio_group`] can't do, before anything is written. +pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> { + for x in [f, target] { + if has_opt(doc, x.obj) { + return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name)); + } + if x.widgets.is_empty() { + return invalid(format!("{} has no button", x.name)); + } + } + if target.locked() { + return invalid(format!("{} is locked: unlock it first", target.name)); + } + Ok(()) +} + +/// Move every button of radio field `f` into radio field `target`. Each keeps its export value +/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a +/// button added to a group does. A moved button shows selected only if it is `target`'s +/// 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> { + // 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() + && only.obj == target.obj + { + split_merged(doc, target, only)?; + } + let mut taken: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + let selected = target.value.first().cloned(); + detach(doc, f.obj)?; + let moved: HashSet = f.widgets.iter().map(|w| w.obj).collect(); + drop_from_calculation_order(doc, |r| r == f.obj && !moved.contains(&r))?; + let mut kids = Vec::with_capacity(f.widgets.len()); + for w in &f.widgets { + let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + if w.obj == f.obj { + for k in FIELD_ONLY_KEYS { + d.remove(k); + } + } + let mut state = w.on_state.clone(); + if let Some(old) = w.on_state.as_deref().filter(|s| taken.contains(*s)) { + let fresh = (1..=MAX_FRESH_STATES).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)); + if let Some(fresh) = fresh + && rename_state(doc, &mut d, old, &fresh) + { + state = Some(fresh); + } + } + if let Some(s) = &state { + taken.insert(s.clone()); + } + let on = state.is_some() && state == selected; + let shown = match (&state, on) { + (Some(s), true) => Object::Name(crate::name_bytes(s)), + _ => Object::name("Off"), + }; + d.set(b"AS".to_vec(), shown); + d.set(b"Parent".to_vec(), Object::Ref(target.obj)); + doc.set(w.obj, Object::Dict(d)); + kids.push(Object::Ref(w.obj)); + } + doc.update_dict(target.obj, |d| { + let mut list = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + list.extend(kids); + d.set(b"Kids".to_vec(), Object::Array(list)); + })?; + Ok(()) +} + +/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`, +/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other +/// users. Whether `/N` had the state: when it didn't, nothing is changed. +fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { + let (old, new) = (crate::name_bytes(old), crate::name_bytes(new)); + let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false }; + let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned()); + if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) { + return false; + } + for key in [&b"N"[..], b"D", b"R"] { + let Some(mut sub) = states(&ap, key) else { continue }; + if let Some(v) = sub.remove(&old) { + sub.set(new.clone(), v); + ap.set(key.to_vec(), Object::Dict(sub)); + } + } + d.set(b"AP".to_vec(), Object::Dict(ap)); + true +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index 745ab3b341..c62b9bde2c 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -215,7 +215,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { /// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or /// by reference. -fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { +pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { let Some(root) = doc.root() else { return Ok(()) }; let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; @@ -258,40 +258,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul return Ok(0); } // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - 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); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } + let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj }; let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); let mut added = Vec::new(); for p in &targets { @@ -324,3 +291,38 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul redraw_field(doc, name)?; Ok(added.len()) } + +/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that +/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid. +pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result { + let refs = page_refs(doc); + let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut kid = Dict::new(); + for k in WIDGET_KEYS { + if let Some(v) = d.get(k) { + kid.set(k.to_vec(), v.clone()); + } + } + 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); + } + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); + })?; + // The page lists the widget now, not the field. + if let Some(&pr) = model.page.and_then(|p| refs.get(p)) { + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + let swap = |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; + match annots { + Some(Object::Ref(ar)) => { + let a = doc.get(ar).as_array().cloned().unwrap_or_default(); + doc.set(ar, Object::Array(swap(a))); + } + Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, + _ => {} + } + } + Ok(kr) +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..d2c9fedb32 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4577,3 +4577,127 @@ fn set_values_reads_the_form_once_not_per_value() { let reads = form_reads() - before; assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } + +/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`). +fn two_radio_groups(a: &str, b: &str) -> Document { + let mut doc = one_page(); + let radio = |export: &str| NewField::Radio { group: None, export: export.into() }; + assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1"); + assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2"); + doc +} + +fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result { + set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) +} + +fn page_widgets(doc: &Document) -> Vec { + let page = page_refs(doc)[0]; + let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect() +} + +/// #4949: a radio button given its group's name is one of the group's buttons, so the group can +/// be built up by naming buttons alike, as in Acrobat. +#[test] +fn a_radio_button_renamed_to_a_groups_name_joins_the_group() { + let mut doc = two_radio_groups("Yes", "No"); + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + let before = page_widgets(&doc); + assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1"); + assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were"); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["Group1"]); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); + assert_eq!(group.value, ["Yes"], "the group keeps its value"); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Yes".into()), Some("Off".into())]); + assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::>()); + // Choosing the moved button turns the other off: they are one group. + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Off".into()), Some("No".into())]); +} + +/// A joining button whose export value a button of the group already has takes the next free +/// "ChoiceN", in its appearance states too, so the two can be chosen apart. +#[test] +fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2"]); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap(); + let normal = doc.resolve(&normal).as_dict().unwrap().clone(); + assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); +} + +/// 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 = 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)] { + 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)); + 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 d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO))); + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name)); + d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()])); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d.set(b"AS".to_vec(), Object::name("Off")); + let r = doc.add(Object::Dict(d)); + doc.update_dict(af, |a| { + let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(r)); + a.set(b"Fields".to_vec(), Object::Array(list)); + }) + .unwrap(); + 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(); + assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo"); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["solo"]); + let group = field(&all, "solo"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["A", "B"]); + // The joined field's widget became a kid; the moved one shed its field entries. + let kids: Vec = group.widgets.iter().map(|w| w.obj).collect(); + assert!(!kids.contains(&group.obj)); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj)); + let mut on_page = page_widgets(&doc); + on_page.sort(); + let mut kids = kids; + kids.sort(); + assert_eq!(on_page, kids, "the page lists the widgets, not the field"); +} + +/// Only a radio button joins by name: other clashes are refused as before, and a locked group +/// isn't changed. +#[test] +fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() { + let mut doc = two_radio_groups("Yes", "No"); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap(); + assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group"); + assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field"); + set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); + assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group"); + assert_eq!(fields(&doc).len(), 3); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bcba7a0ecd..497a6878a7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4847,8 +4847,8 @@ milestone = "M6" status = "shipped" commands = ["form.add.radio"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] -notes = "new group Group1… or join the selected group with the next ChoiceN" +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::a_radio_button_renamed_to_a_groups_name_joins_the_group", "crates/forms/src/tests.rs::a_joining_radio_button_with_a_taken_export_value_takes_a_free_one", "crates/forms/src/tests.rs::radio_fields_that_are_their_own_widget_join_and_are_joined", "crates/automation/tests/it/automation.rs::renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools"] +notes = "new group Group1… or join the selected group with the next ChoiceN; renaming a radio button (Properties or form_set_props) to another radio group's name moves its buttons into that group (#4949), a clashing export value becoming the next free ChoiceN; refused when either group lists its values in /Opt; a button's export value is not yet editable in Properties" [[feature]] id = "form.check-box-style" From 27a0cd6eb27215627c7ad507bf1b03766b86c468 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 08:17:02 -0400 Subject: [PATCH 29/75] M6: joining radio buttons that share an export value keep sharing it Review follow-up. A clash is checked against the group's own values only, and each clashing value gets one fresh ChoiceN shared by every joining button that had it, so a button repeated on another page stays in unison. The group's value selects a moved button only when one of the group's own buttons has it. A joining field that is its own widget leaves the calculation order too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/join.rs | 44 ++++++++++++++++++++------------- crates/forms/src/tests.rs | 26 +++++++++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index d40784264d..99fa4d29a1 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -2,7 +2,7 @@ //! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2 //! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use super::lifecycle::{drop_from_calculation_order, split_merged}; use super::tree::detach; @@ -15,7 +15,7 @@ const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", /// 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 Table 230). +/// 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")) } @@ -38,7 +38,7 @@ pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<() /// Move every button of radio field `f` into radio field `target`. Each keeps its export value /// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a -/// button added to a group does. A moved button shows selected only if it is `target`'s +/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s /// 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> { @@ -49,11 +49,26 @@ pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> { split_merged(doc, target, only)?; } - let mut taken: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); - let selected = target.value.first().cloned(); + 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)?; - let moved: HashSet = f.widgets.iter().map(|w| w.obj).collect(); - drop_from_calculation_order(doc, |r| r == f.obj && !moved.contains(&r))?; + // `f` is no longer a field, even when it stays on as a widget. + drop_from_calculation_order(doc, |r| r == f.obj)?; let mut kids = Vec::with_capacity(f.widgets.len()); for w in &f.widgets { let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); @@ -63,16 +78,11 @@ 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().filter(|s| taken.contains(*s)) { - let fresh = (1..=MAX_FRESH_STATES).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)); - if let Some(fresh) = fresh - && rename_state(doc, &mut d, old, &fresh) - { - state = Some(fresh); - } - } - if let Some(s) = &state { - taken.insert(s.clone()); + if let Some(old) = w.on_state.as_deref() + && let Some(Some(fresh)) = renamed.get(old) + && rename_state(doc, &mut d, old, fresh) + { + state = Some(fresh.clone()); } let on = state.is_some() && state == selected; let shown = match (&state, on) { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d2c9fedb32..48148402b2 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4636,6 +4636,32 @@ fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); } +/// Buttons of the joining group that share an export value (a button repeated on another page, +/// on together) still share it after the join, under one fresh name. +#[test] +fn joining_radio_buttons_that_share_a_value_keep_sharing_it() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = pdfcraft_cos::Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + 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] From 2c054abdce3beaf2654bec33b33205112ac06ce1 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 08:54:15 -0400 Subject: [PATCH 30/75] M6: a radio group with an indirect /Kids keeps its buttons when joined Review follow-up (CodeRabbit): the join read the target's /Kids only when it was a direct array, so an indirect one was replaced by the moved buttons alone and the group's own buttons were lost. It now reads through the reference with tree.rs's list_of, as link() does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/join.rs | 11 +++++------ crates/forms/src/author/tree.rs | 2 +- crates/forms/src/tests.rs | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index 99fa4d29a1..f98bd42cb9 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use super::lifecycle::{drop_from_calculation_order, split_merged}; -use super::tree::detach; +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` @@ -94,11 +94,10 @@ pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> doc.set(w.obj, Object::Dict(d)); kids.push(Object::Ref(w.obj)); } - doc.update_dict(target.obj, |d| { - let mut list = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - list.extend(kids); - d.set(b"Kids".to_vec(), Object::Array(list)); - })?; + // `/Kids` may be an indirect array: read through it so the group's own buttons stay. + let mut list = list_of(doc, target.obj, b"Kids"); + list.extend(kids); + doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?; Ok(()) } diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e1..005452be34 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option { } /// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). -fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { +pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 48148402b2..4648167c4f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4636,6 +4636,20 @@ fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); } +/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it. +#[test] +fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() { + let mut doc = two_radio_groups("Yes", "No"); + let group = field(&fields(&doc), "Group1").obj; + let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap(); + let held = doc.add(kids); + doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); +} + /// Buttons of the joining group that share an export value (a button repeated on another page, /// on together) still share it after the join, under one fresh name. #[test] From 0ea9a23466268d369a39e5621853f885819d50c5 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 09:16:35 -0400 Subject: [PATCH 31/75] M6: editing or sorting a list's items keeps its selected indices on its items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_props rewrote /Opt (an items edit or "Sort items") but left /I, the selected positions in /Opt (ISO 32000-2 §12.7.5.4), so /I named whatever now sat at the old positions. It now re-points /I at the selected values' new positions, by set_value's rule, and removes it when none is offered; a field /TI past the end of the new list is removed. Adapted from pdfcer (MIT, same author) 149db0662ecfe5b23a050cdb7513821da918d7cb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 27 ++++++++++++ crates/forms/src/author/edit.rs | 27 ++++++++++++ crates/forms/src/tests.rs | 52 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..8641684efa 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9175,3 +9175,30 @@ 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()); } + +/// `/I` of the top-level field `name` in the saved file `path`. +fn saved_selection_indices(path: &Path, name: &str) -> Option> { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let f = all + .iter() + .filter_map(|o| d.resolve(o).as_dict().cloned()) + .find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name)) + .unwrap(); + f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() { + let dir = workdir("choice-index"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" }); + ok(&mut a, "form_add_field", args); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" })); + assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); +} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4fc..c7df5481cd 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -14,6 +14,24 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) } +/// Re-point a choice field's `/I` (and `/TI`), which are positions in `/Opt`, at the new list +/// `exports` after `/Opt` was replaced (ISO 32000-2 §12.7.5.4): `/I` names the positions of the +/// selected `values` still offered, and a `/TI` past the end is dropped. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) { + let mut indices: Vec = values.iter().filter_map(|v| exports.iter().position(|e| e == v)).collect(); + indices.sort_unstable(); + indices.dedup(); + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices.into_iter().map(|i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); + } + let top = d.get(b"TI").and_then(Object::as_int); + if top.is_some_and(|t| usize::try_from(t).map_or(true, |t| t >= exports.len())) { + d.remove(b"TI"); + } +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -190,8 +208,11 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; if let Some(opts) = &props.options { d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + new_exports = Some(opts.clone()); } // "Sort items": the list itself is kept in order (export values stay with their items). if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { @@ -211,6 +232,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Option> { + let f = field(&fields(doc), name).clone(); + let d = doc.get(f.obj).as_dict().cloned().unwrap(); + d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_repoints_its_selected_indices() { + let mut doc = one_page(); + let list = + add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None) + .unwrap(); + set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2])); + let f = field(&fields(&doc), &list).clone(); + doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap(); + // Removing A moves B and C up one place. + let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() }; + set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1])); + assert!(doc.get(f.obj).as_dict().unwrap().get(b"TI").is_some(), "row 2 still exists"); + // Removing B leaves only C selected; the top row now points past the end. + set_props(&mut doc, &list, &opts(&["C"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0])); + assert!(doc.get(f.obj).as_dict().unwrap().get(b"TI").is_none(), "a /TI past the list is dropped"); + // No selected value is offered any more. + set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), None); + let doc = reopen(&doc); + assert_eq!(selection_indices(&doc, &list), None); +} + +#[test] +fn sorting_a_combo_boxs_items_repoints_its_selected_index() { + let mut doc = one_page(); + let combo = add_field( + &mut doc, + 0, + [50.0, 600.0, 250.0, 620.0], + &NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false }, + None, + ) + .unwrap(); + set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &combo), Some(vec![0])); + set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // apple, Fig, Pear: the selection follows Pear to the end. + assert_eq!(selection_indices(&doc, &combo), Some(vec![2])); + assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bcba7a0ecd..2511971f80 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4991,8 +4991,8 @@ 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"] -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, 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" +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::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]] id = "form.props-actions" From ee6f2a017f85e57d1645bf895ce306dc2a7b8ce0 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:25:48 +0100 Subject: [PATCH 32/75] fix(ui): enforce copy permission before reading bookmark styles --- crates/ui-egui/src/bookmark_styles_ui.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index 718e582f70..dcdcfc80ef 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -36,6 +36,13 @@ impl PdfCraftApp { /// largest is offered for level 1 when there is more than one (headings are larger than the /// text around them; a single style has no headings to tell apart). pub fn open_text_style_bookmarks(&mut self, id: DocId) { + // The style list includes samples copied from the document's text. Enforce the PDF's + // copy permission before extracting any of that text, not only when creating bookmarks. + let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy())); + if !can_copy { + self.notify_tr("Copying text and images is not permitted"); + return; + } let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); let first = (styles.len() > 1).then_some(0); self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] }; From 67ecccfc23bc78b09b68b6e52c6b5416168ca2ee Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:40:18 +0100 Subject: [PATCH 33/75] fix(ui): reuse translated copy-permission message --- crates/ui-egui/src/bookmark_styles_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index dcdcfc80ef..890a5f2efd 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -40,7 +40,7 @@ impl PdfCraftApp { // copy permission before extracting any of that text, not only when creating bookmarks. let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy())); if !can_copy { - self.notify_tr("Copying text and images is not permitted"); + self.notify_tr("The document's security settings don't allow copying text and images"); return; } let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); From 3ed4f2379e65c3b4f46fe0af3d9b1d780370df48 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 09:51:32 -0400 Subject: [PATCH 34/75] M6: a list box's top row follows its options when they change Changing a list box's options could leave /TI (the first visible row) pointing at a row that moved or no longer exists. A widget's own /TI is read before the field's, and one past the end draws no options at all. set_props now runs the same ListRows::top_showing rule set_value uses (ISO 32000-2 12.7.5.4): /TI stays put while the selected rows are still visible, otherwise it scrolls to them, clamped to the last full page. The field's /TI is updated, and so is every widget that carries its own. The scroll helpers move out of set_value so both paths share them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 29 ++++++++++++++++--------- crates/forms/src/tests.rs | 30 +++++++++++++++++++++++--- crates/forms/src/values.rs | 38 +++++++++++++++++++++------------ parity/acrobat-features.toml | 2 +- 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index c7df5481cd..d893125f43 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -14,22 +14,19 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) } -/// Re-point a choice field's `/I` (and `/TI`), which are positions in `/Opt`, at the new list -/// `exports` after `/Opt` was replaced (ISO 32000-2 §12.7.5.4): `/I` names the positions of the -/// selected `values` still offered, and a `/TI` past the end is dropped. -fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) { +/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was +/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered, by +/// `set_value`'s rule. Returns them. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) -> Vec { let mut indices: Vec = values.iter().filter_map(|v| exports.iter().position(|e| e == v)).collect(); indices.sort_unstable(); indices.dedup(); if indices.is_empty() { d.remove(b"I"); } else { - d.set(b"I".to_vec(), Object::Array(indices.into_iter().map(|i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); - } - let top = d.get(b"TI").and_then(Object::as_int); - if top.is_some_and(|t| usize::try_from(t).map_or(true, |t| t >= exports.len())) { - d.remove(b"TI"); + d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); } + indices } /// Change a field's properties (General and Options tabs) and redraw it. @@ -191,6 +188,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result Option { + doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int()) +} + +#[test] +fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() { + let objs: Vec = vec![ + "<< /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 /Annots [7 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) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let widget = ObjRef::new(7, 0); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(selection_indices(&doc, "pick"), Some(vec![0])); + // The widget's own /TI is read before the field's; row 5 no longer exists. + assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0"); + let doc = reopen(&doc); + assert_eq!(top_row(&doc, widget), Some(0)); +} + #[test] fn sorting_a_combo_boxs_items_repoints_its_selected_index() { let mut doc = one_page(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a70..a060822ef0 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -288,14 +288,8 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr indices.sort_unstable_by_key(Object::as_int); // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + let top = list_scroll(f, &picked, f.options.len()); doc.update_dict(f.obj, |d| { match exports.as_slice() { [] => { @@ -315,15 +309,10 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); } })?; - // A widget with its own `/TI` (read before the field's) scrolls too. if let Some((old, new)) = top && old != new { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } + scroll_widgets(doc, f, new)?; } redraw(doc, f, &exports, scripts) } @@ -333,6 +322,27 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } +/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of +/// its `count` options are among the rows it draws, as in other viewers: a pick below the last +/// visible row would otherwise not show (#1524). `None` for any other field. +pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> { + let w = f.widgets.first().filter(|_| f.kind == FieldKind::List)?; + w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + (rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count)) + }) +} + +/// Give every widget of `f` with its own `/TI` (read before the field's) the first row `top`. +pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, top: usize) -> Result<(), FormError> { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(top).unwrap_or(0))))?; + } + } + Ok(()) +} + /// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { let v = on.unwrap_or("Off"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2511971f80..bc61a611ca 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::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::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 d39fe5b2336d36c5801904cb6f4b90a11d332df7 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 09:37:36 -0400 Subject: [PATCH 35/75] M6: clearing a field's default leaves it with none, not its parent field's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /DV is inheritable (ISO 32000-2 §12.7.4.1). set_props removed a cleared /DV, which brought back a parent field's, so Reset Form restored the parent's default. When an ancestor sets /DV, a cleared default is now written empty (/Off for buttons, () otherwise); with none it is removed as before. New tree::ancestor_sets, cycle-safe and capped. Adapted from pdfcer (MIT, same author) 6af2773ccf599b416fab379673119c6b37c55a2c. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 25 +++++++++++ crates/forms/src/author/edit.rs | 9 +++- crates/forms/src/author/tree.rs | 18 ++++++++ crates/forms/src/tests.rs | 54 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..7bbfee1d5d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9175,3 +9175,28 @@ 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()); } + +/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, +/// even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-default"); + 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_set_props", json!({ "doc": doc, "field": "address.city", "default": "" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } })); + ok(&mut a, "form_reset", json!({ "doc": doc })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]); +} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4fc..0e45f12086 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,5 +1,5 @@ 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::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::*; /// Swap width and height, keeping the center. `None` when that is not a usable widget. @@ -173,6 +173,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), + // `/DV` is inheritable: with an ancestor's in place, removing the field's own would + // bring that one back, so the field keeps an empty default of its own instead. + Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => { + d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec())) + } + Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")), Some(None) => { d.remove(b"DV"); } diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e1..605ce95c35 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { add } +/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without +/// its own entry. +pub(super) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + return false; + } + let Some(d) = doc.get(p).as_dict().cloned() else { return false }; + if d.contains(key) { + return true; + } + parent = d.reference(b"Parent"); + } + false +} + /// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { if add.iter().next().is_some() { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..43f933fe0d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4577,3 +4577,57 @@ fn set_values_reads_the_form_once_not_per_value() { let reads = form_reads() - before; assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } + +#[test] +fn clearing_a_kids_default_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let all = fields(&doc); + let city = field(&all, "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text); + assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap(); + reset(&mut doc, None).unwrap(); + assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it"); + // Without a parent default, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none()); +} + +#[test] +fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() { + // A check box, then a radio button (flags 49152, inherited from the parent). + for flags in ["", "/Ff 49152"] { + 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 /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(), + "<< /Length 0 >> +stream + +endstream".into(), + ]); + let kind = field(&fields(&doc), "opts.a").kind; + assert_eq!(kind == FieldKind::Radio, !flags.is_empty()); + set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}"); + let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) }; + set_value(&mut doc, "opts.a", &on).unwrap(); + assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]); + reset(&mut doc, None).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bcba7a0ecd..8dc8ce17fd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4991,8 +4991,8 @@ 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"] -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, 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" +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::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] +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 (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, 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]] id = "form.props-actions" From 12c9051fd8b717858b725d7d0826ddb1fbbfd5ee Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 11:01:12 -0400 Subject: [PATCH 36/75] M6: clearing a field's value leaves it empty, not its parent field's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /V is inheritable like /DV (ISO 32000-2 §12.7.4.1). Clearing a text or choice field's value removed its own /V, so a parent field's /V showed through: Reset Form on a kid with no default refilled it with the parent's value. When an ancestor sets /V, an empty value is now written instead of removing the key. Check boxes and radio buttons already write /Off. Test clearing_a_kids_value_does_not_bring_back_its_parents covers a text kid and a list box kid through reset and through set_value; each half of the fix was switched off in turn and the test failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author.rs | 2 ++ crates/forms/src/author/tree.rs | 2 +- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/forms/src/values.rs | 6 +++++- parity/acrobat-features.toml | 2 +- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index ce89fad806..76ae937249 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -15,6 +15,8 @@ mod lifecycle; mod look; mod tree; +pub(crate) use tree::ancestor_sets; + pub use edit::set_props; pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field}; pub(crate) use look::button_appearance; diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 605ce95c35..9eb0791b75 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -319,7 +319,7 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { /// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without /// its own entry. -pub(super) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { +pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { let mut seen = std::collections::HashSet::new(); let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); while let Some(p) = parent { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 43f933fe0d..b9153ff6e3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4602,6 +4602,34 @@ fn clearing_a_kids_default_does_not_bring_back_its_parents() { assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none()); } +#[test] +fn clearing_a_kids_value_does_not_bring_back_its_parents() { + // A text kid, then a list box kid, under a parent with both a value and a default. + for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] { + 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 /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(), + ]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + set_value(&mut doc, "address.city", &set).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}"); + reset(&mut doc, None).unwrap(); + let doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value); + let mut doc = doc; + set_value(&mut doc, "address.city", &set).unwrap(); + let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) }; + set_value(&mut doc, "address.city", &clear).unwrap(); + let f = field(&fields(&reopen(&doc)), "address.city").clone(); + assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + } +} + #[test] fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() { // A check box, then a radio button (flags 49152, inherited from the parent). diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a70..738c3b7428 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -207,8 +207,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; + let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { - if t.is_empty() { + if t.is_empty() && !shadow { d.remove(b"V"); } else { d.set(b"V".to_vec(), PdfString::text(t)); @@ -296,8 +297,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr }), _ => None, }; + // An empty value of its own keeps a parent field's /V from showing through. + let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { match exports.as_slice() { + [] if shadow => d.set(b"V".to_vec(), PdfString::text("")), [] => { d.remove(b"V"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8dc8ce17fd..977a1503fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4727,7 +4727,7 @@ milestone = "M6" status = "shipped" commands = ["form.clear"] tools = ["form_reset"] -evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices"] +evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::clearing_a_kids_value_does_not_bring_back_its_parents"] notes = "back to /DV; undoable" [[feature]] From 7a6177759cbcd80b5dc52f796c65011287187e23 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 11:13:16 -0400 Subject: [PATCH 37/75] M6: each list box widget with its own top row scrolls by itself A widget's own /TI is read before the field's, so each one scrolls by its own top row and size (ListRows::top_showing). Before, the widgets were only moved, all to the first widget's new row, when the first widget itself scrolled: a second widget scrolled past the remaining options, or away from a new value the first already showed, kept its stale row. Test each_widget_with_its_own_top_row_scrolls_by_itself covers both set_props (options shrink) and set_value; restoring the first-widget gate in either caller makes it fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 8 ++++---- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/forms/src/values.rs | 24 ++++++++++++++---------- parity/acrobat-features.toml | 2 +- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index d893125f43..9e810c6709 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -188,7 +188,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result Vec { + vec![ + "<< /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 /Annots [7 0 R 8 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(), + format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(), + ] + }; + let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0)); + // The options shrink: the first widget already shows A, the other's row 4 is gone. + let mut doc = document(&objs("/V (A) /I [0]")); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(top_row(&doc, first), None); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A"); + // A new value the first widget already shows, below the other's view. + let mut doc = document(&objs("/V (B) /I [1]")); + set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap(); + assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move"); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A"); +} + #[test] fn sorting_a_combo_boxs_items_repoints_its_selected_index() { let mut doc = one_page(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index a060822ef0..7f02c6e81c 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -309,11 +309,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); } })?; - if let Some((old, new)) = top - && old != new - { - scroll_widgets(doc, f, new)?; - } + scroll_widgets(doc, f, &picked, f.options.len())?; redraw(doc, f, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), @@ -326,18 +322,26 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr /// its `count` options are among the rows it draws, as in other viewers: a pick below the last /// visible row would otherwise not show (#1524). `None` for any other field. pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> { - let w = f.widgets.first().filter(|_| f.kind == FieldKind::List)?; + f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count)) +} + +/// A list box widget's first row before and after scrolling so `picked` shows. +fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> { w.list.map(|rows| { let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; (rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count)) }) } -/// Give every widget of `f` with its own `/TI` (read before the field's) the first row `top`. -pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, top: usize) -> Result<(), FormError> { +/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and +/// size, so `picked` shows in each. +pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> { for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(top).unwrap_or(0))))?; + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) + && let Some((old, new)) = widget_scroll(w, picked, count) + && old != new + { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; } } Ok(()) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bc61a611ca..dae9ece479 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::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::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 5abf32ed2f067e02f0889f439d2bd58dee64a02d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 12:03:33 -0400 Subject: [PATCH 38/75] 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 39/75] 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 40/75] 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 b8871e872edf9396925a3d539d86b48bcfdb7c3c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 13:14:53 -0400 Subject: [PATCH 41/75] 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 42/75] 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 0cd6a197e40e31b1c0dd0cb84b047f6df1e295be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:47:34 +0000 Subject: [PATCH 43/75] M6: a radio button without its /Parent link leaves its group's /Kids too Deleting one widget took it out of the field tree through its /Parent, which a damaged file can leave out or point at another field; the group kept the widget in its /Kids with no page showing it, and the delete reported success. The field that owns the widget drops it from its own /Kids as well (CodeRabbit on #4631). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/forms/src/author/lifecycle.rs | 9 +++++++++ crates/forms/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index 4fb045d7b8..d5d7d14cdd 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -219,6 +219,15 @@ pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<() drop_from_calculation_order(doc, |r| r == w.obj)?; let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); let mut changed = false; + // The field's own `/Kids` is what made the widget its: `detach` follows the widget's + // `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep + // a widget no page shows. + if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && kids.iter().any(|k| k.as_ref() == Some(w.obj)) + { + d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect())); + changed = true; + } if per_widget_opt && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) && opt.len() == f.widgets.len() diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b85aeb726c..a0d57a1b5f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4693,6 +4693,29 @@ fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { assert_eq!(annots(&doc), [6, 7, 8]); } +#[test] +fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() { + // A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent. + for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] { + let mut doc = radio_group_form(); + let medium = ObjRef::new(7, 0); + let mut w = doc.get(medium).as_dict().cloned().unwrap(); + w.remove(b"Parent"); + if let Some(p) = parent.clone() { + w.set(b"Parent".to_vec(), p); + } + doc.set(medium, Object::Dict(w)); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "/Parent {parent:?}"); + let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap(); + assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}"); + assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched"); + assert_eq!(annots(&doc), [6, 8, 10, 11]); + } +} + #[test] fn deleting_a_widget_that_is_not_there_changes_nothing() { let mut doc = radio_group_form(); From cace97e7b2607d31e703bb04c17fa7975f40ec39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:46:00 +0000 Subject: [PATCH 44/75] 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 45/75] 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 6e0eec60e92601b73400f92f5869886a6eb8ea58 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 22:53:37 -0400 Subject: [PATCH 46/75] C011: sorting keeps which of two items with one export value is selected Sorting a field's own items carries its /I positions through the sort (ISO 32000-2 12.7.5.4) instead of re-deriving them from /V, which names only the export value. Values no carried position covers still take their first position. items_to_sort and write_sorted_opt keep set_props under the complexity limit. Test: sorting_keeps_which_of_two_items_with_one_export_value_is_selected (fails before: /I [0], Alpha, instead of [2], Zeta). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 79 ++++++++++++++++++++++++--------- crates/forms/src/tests.rs | 18 ++++++++ 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index d163923e78..7f63aa6118 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -15,10 +15,17 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { } /// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was -/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered, by -/// `set_value`'s rule. Returns them. -fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) -> Vec { - let mut indices: Vec = values.iter().filter_map(|v| exports.iter().position(|e| e == v)).collect(); +/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` +/// are the old `/I` positions carried into the new list, which tell apart options sharing an +/// export value; a selected value none of them covers takes its first position, by `set_value`'s +/// rule. Returns them. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec { + let mut indices: Vec = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect(); + for v in values { + if !indices.iter().any(|&i| exports.get(i) == Some(v)) { + indices.extend(exports.iter().position(|e| e == v)); + } + } indices.sort_unstable(); indices.dedup(); if indices.is_empty() { @@ -29,6 +36,46 @@ fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) -> Vec indices } +/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`. +fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec { + let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else { + return Vec::new(); + }; + let positions = doc.resolve(&i); + positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default() +} + +/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else +/// the field's own, whose selection, `/I` included, moves with them. +fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec) { + match &props.options { + Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()), + None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())), + } +} + +/// Sort a choice field's items (export value, display text) by display text, ignoring case, and +/// write them as `/Opt`; export values stay with their items. Returns the export values in their +/// new order and the `old` positions carried through the sort. +fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec, Vec) { + let mut order: Vec = (0..items.len()).collect(); + order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase())); + let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect(); + let opt = sorted + .iter() + .map(|(e, disp)| { + if e == disp { + Object::String(PdfString::text(disp)) + } else { + Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) + } + }) + .collect(); + d.set(b"Opt".to_vec(), Object::Array(opt)); + let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect(); + (sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -137,6 +184,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result> = None; + let mut kept = Vec::new(); if let Some(opts) = &props.options { d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); new_exports = Some(opts.clone()); } // "Sort items": the list itself is kept in order (export values stay with their items). if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); - new_exports = Some(items.into_iter().map(|(e, _)| e).collect()); + let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection); + new_exports = Some(exports); + kept = carried; } if let Some(exports) = &new_exports && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - list_picked = Some((repoint_selection(d, &f.value, exports), exports.len())); + list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len())); } if let Some(q) = props.quadding { d.set(b"Q".to_vec(), Object::Int(q)); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a1b791c4fb..8326f62cb7 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4746,3 +4746,21 @@ fn sorting_a_combo_boxs_items_repoints_its_selected_index() { assert_eq!(selection_indices(&doc, &combo), Some(vec![2])); assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]); } + +#[test] +fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { + let mut doc = one_page(); + let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap(); + let f = field(&fields(&doc), &list).clone(); + let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]); + // Zeta and Alpha both export "x"; `/I` says Zeta is the one selected. + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")])); + d.set(b"V".to_vec(), PdfString::text("x")); + d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)])); + }) + .unwrap(); + set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x". + assert_eq!(selection_indices(&doc, &list), Some(vec![2])); +} From 5716a95f8da3f8ab70ba36f8077dd42abd53e314 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 22:53:55 -0400 Subject: [PATCH 47/75] C010: joining a group redraws only the buttons that moved redraw_field after the join redrew every button of the target group, so a button with its own appearance states lost them to the default marks. Only the moved buttons are now redrawn under the group's name. Test: joining_a_group_leaves_its_own_buttons_appearances_as_they_are (fails before: the group's own button gets new /AP streams). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 15 ++++++++++++--- crates/forms/src/tests.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 0ffb8eb3e0..dcb306e4a2 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,5 +1,5 @@ use super::join::{check_join, join_radio_group}; -use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_field, redraw_widgets}; +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::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; @@ -376,12 +376,21 @@ fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &F } /// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its -/// old name `name` first, then its buttons move and the whole group is redrawn as `new_name`. +/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the +/// group's own buttons keep their appearances. fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option) -> Result<(), FormError> { if let Some(group) = join { redraw_widgets(doc, name, only)?; join_radio_group(doc, f, group)?; - redraw_field(doc, new_name) + let moved: Vec = fields(doc) + .into_iter() + .find(|g| g.name == new_name) + .map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + for index in moved { + redraw_widgets(doc, new_name, Some(index))?; + } + Ok(()) } else { redraw_widgets(doc, new_name, only) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 77d8c62648..62427f1259 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4663,6 +4663,21 @@ fn a_radio_button_renamed_to_a_groups_name_joins_the_group() { assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Off".into()), Some("No".into())]); } +/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may +/// be custom ones a redraw would replace. +#[test] +fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() { + let mut doc = two_radio_groups("Yes", "No"); + let own = field(&fields(&doc), "Group1").widgets[0].obj; + let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned()); + let before = look(&doc); + assert!(before.is_some()); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + assert_eq!(look(&doc), before); + let group = field(&fields(&doc), "Group1").clone(); + assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states"); +} + /// A joining button whose export value a button of the group already has takes the next free /// "ChoiceN", in its appearance states too, so the two can be chosen apart. #[test] From ac40f62294a593b2fcc4224b259c58685c3aa5e3 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 22:54:50 -0400 Subject: [PATCH 48/75] C012 test: a cleared choice kid has no selected value, exactly Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4634d2bdf2..dea60c4e80 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4669,7 +4669,11 @@ fn clearing_a_kids_value_does_not_bring_back_its_parents() { let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) }; set_value(&mut doc, "address.city", &clear).unwrap(); let f = field(&fields(&reopen(&doc)), "address.city").clone(); - assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + if ft.contains("Ch") { + assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value); + } else { + assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + } } } From c14910b182e9dd9d8bf574475b2b64e3b4ff9973 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 11:55:53 +0100 Subject: [PATCH 49/75] docs(ui): a link's tip shows where a press acts on it, with Select or Link Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_cursor.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index d8ae61bc39..382f5efb63 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -6,10 +6,10 @@ //! showed over every tool though only the Select tool follows links, and the I-beam of the text //! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the //! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is -//! that claim's own (a link's target, so only where a press follows the link), or else what lies -//! under the pointer says (a comment, or a signature's validation badge), with any tool, as -//! Acrobat shows a comment's tooltip. A test in `page_pointer/tests.rs` keeps page modules from -//! setting the cursor. +//! that claim's own (a link's target, so only where a press acts on the link: the Select tool +//! follows it, the Link tool edits it), or else what lies under the pointer says (a comment, or a +//! signature's validation badge), with any tool, as Acrobat shows a comment's tooltip. A test in +//! `page_pointer/tests.rs` keeps page modules from setting the cursor. use egui::CursorIcon; From 1e01497649c9ad1c2947b76d4656b0943f8d0b6a Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 12:09:37 +0100 Subject: [PATCH 50/75] fix(ui): Update prepares the unmarked pages once, and shows the thumbnail while one renders The preview cloned and wrote the whole document on the UI thread for every page it stepped to, and showed a blank sheet while the page rendered. Document::without_marks now removes the marks of a kind from every page once per dialog (UnmarkedPages), each page shown renders from it off the UI thread, and the page's thumbnail stays until it arrives. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 18 ++++---- crates/engine/src/text_preview.rs | 58 ++++++++++++++++++-------- crates/ui-egui/src/dialogs/marks_ui.rs | 49 ++++++++++++---------- crates/ui-egui/tests/it/marks.rs | 27 +++++++++--- 5 files changed, 99 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 889b839368..1ec6e57d6d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -66,7 +66,7 @@ pub use links::{ pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; -pub use text_preview::TextEditPreview; +pub use text_preview::{TextEditPreview, UnmarkedPages}; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 248df94d6b..c58ba90129 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3004,13 +3004,14 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { /// Update previews its marks on the page without the ones it replaces: a yellow background /// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. -/// The other kinds of mark and the page's own content stay, a page without that kind has nothing -/// to prepare, and preparing changes nothing in the document. +/// The other kinds of mark and the page's own content stay, every page is prepared at once (the +/// dialog steps through them), a document without that kind has nothing to prepare, and +/// preparing changes nothing in the document. #[test] fn update_previews_on_the_page_without_the_marks_it_replaces() { let (mut s, id) = session_with(2); let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; - s.apply(id, Edit::AddBackground { pages: vec![0], settings: yellow, replace: false, file: None }).unwrap(); + s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap(); let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); let doc = s.get(id).unwrap(); @@ -3022,18 +3023,19 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); - let page = doc.page_without_marks(0, MarkKind::Background).unwrap().expect("page 1 has a background").render_background(1.0).unwrap(); + let unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let page = unmarked.render_page(0, 1.0).unwrap(); assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); assert!(reds(&page) > 50, "the watermark stays"); assert!(dark(&page) > 10, "the page text stays"); - let page = doc.page_without_marks(0, MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_background(1.0).unwrap(); + let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap(); assert_eq!(reds(&page), 0, "without the watermark"); assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); - assert!(doc.page_without_marks(1, MarkKind::Background).unwrap().is_none(), "page 2 has none: its thumbnail is the page"); - assert!(doc.page_without_marks(0, MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); - assert!(doc.page_without_marks(9, MarkKind::Background).is_err(), "no page 10"); + assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); let doc = s.get(id).unwrap(); assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); } diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d19272fcef..5f39945a14 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -19,20 +19,41 @@ impl TextEditPreview { } pub fn render_background(&self, scale: f32) -> Result { - if !scale.is_finite() || scale <= 0.0 { - return Err("preview scale must be finite and positive".into()); - } - guard(|| { - let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); - let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); - if let Some(e) = &out.error { - return Err(e.clone()); - } - Ok(out) - })? + render_one(&self.bytes, &self.config, self.page, scale) } } +/// A document's pages without its marks of one kind ([`Document::without_marks`]). +pub struct UnmarkedPages { + bytes: Arc>, + config: RenderConfig, +} + +impl UnmarkedPages { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + pub fn render_page(&self, page: usize, scale: f32) -> Result { + render_one(&self.bytes, &self.config, page, scale) + } +} + +/// `page` of `bytes` at `scale`, on this thread. +fn render_one(bytes: &Arc>, config: &RenderConfig, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(bytes.clone(), config.clone()); + let out = renderer.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? +} + impl Document { /// Background for retyping an added text item, without changing its saved content. pub fn added_text_preview(&self, page: usize, index: usize) -> Result { @@ -91,21 +112,24 @@ impl Document { })? } - /// The page without its marks of `kind`: what Update replaces them on, so its preview doesn't - /// draw the new marks over the old ones (or tint the old background). `None` when the page has - /// none, and its thumbnail already is that page. Read-only, like [`Self::text_edit_preview`]. - pub fn page_without_marks(&self, page: usize, kind: crate::MarkKind) -> Result, String> { + /// The document without its marks of `kind`, on every page: what Update replaces them on, so + /// its preview doesn't draw the new marks over the old ones (or tint the old background). + /// Made once for all the pages a dialog steps through, as cloning and writing the document + /// costs about a save. `None` when the document has none, and the thumbnails already are + /// those pages. Read-only, like [`Self::text_edit_preview`]. + pub fn without_marks(&self, kind: crate::MarkKind) -> Result, String> { if !self.marks().contains(&kind) { return Ok(None); } guard(|| { let editor = self.editor.as_ref().ok_or("the document can't be read")?; let mut cos = editor.cos.clone(); - if pdfcraft_edit::remove_marks(&mut cos, &[page], kind).map_err(|e| e.to_string())? == 0 { + let n = pdfcraft_model::pages(&cos).len(); + if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::>(), kind).map_err(|e| e.to_string())? == 0 { return Ok(None); } let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(TextEditPreview { page, bytes, config: self.config.clone() })) + Ok(Some(UnmarkedPages { bytes, config: self.config.clone() })) })? } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 62fb3ce33b..2efc10ecce 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -183,26 +183,29 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } -/// In Update, the page previewed without the marks the dialog replaces. Its thumbnail still has -/// them, so the new marks would be drawn over the old ones, and a new background colour would tint -/// the old one. Rendered off the UI thread like the thumbnail, once per page shown. +/// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still +/// have them, so the new marks would be drawn over the old ones, and a new background colour +/// would tint the old one. The document without them is made once per dialog (it costs about a +/// save), and each page shown is rendered from it off the UI thread, like the thumbnails. #[derive(Default)] pub(crate) struct UnmarkedPage { - /// The document, its edit generation, the page and the kind of mark taken away. - key: Option<(pdfcraft_engine::DocId, u64, usize, MarkKind)>, + /// The document, its edit generation and the kind of mark taken away. + key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, renderer: Option, - texture: Option, - tag: Option, + /// The page and scale last asked for. + asked: Option<(usize, u64)>, + /// The page and scale the texture shows. + texture: Option<(usize, u64, egui::TextureHandle)>, } impl UnmarkedPage { pub(crate) fn pending(&self) -> bool { - self.renderer.is_some() && self.texture.is_none() + self.renderer.is_some() && self.asked.is_some() && self.texture.as_ref().map(|(p, t, _)| (*p, *t)) != self.asked } - /// The picture to preview `page` on in place of its thumbnail: `None` when the thumbnail is - /// that page (not Update, or no marks of `kind` on it, or the page can't be prepared), and - /// `Some(None)` while it renders, when the page is a blank sheet. + /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: + /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` is + /// still rendering (its thumbnail shows until then, rather than a blank sheet). fn picture( &mut self, ctx: &egui::Context, @@ -210,39 +213,39 @@ impl UnmarkedPage { page: usize, kind: MarkKind, replace: bool, - ) -> Option> { - let key = replace.then(|| (id, doc.edit_generation(), page, kind)); + ) -> Option { + let key = replace.then(|| (id, doc.edit_generation(), kind)); if self.key != key { *self = Self { key, ..Default::default() }; if replace { - self.renderer = doc.page_without_marks(page, kind).ok().flatten().map(|p| p.renderer()); + self.renderer = doc.without_marks(kind).ok().flatten().map(|p| p.renderer()); } } let renderer = self.renderer.as_ref()?; // At the preview's size in pixels. let p = doc.info.pages.get(page)?; let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); - let tag = (scale * 1000.0) as u64; - if self.tag != Some(tag) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); - self.tag = Some(tag); + let asked = (page, (scale * 1000.0) as u64); + if self.asked != Some(asked) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag: asked.1, ..Default::default() }]); + self.asked = Some(asked); } if let Some(out) = renderer.try_recv() { if out.error.is_some() { - // The thumbnail, as before, rather than no page at all. + // The thumbnails, as before, rather than no page at all. self.renderer = None; self.texture = None; return None; } - if out.request.tag == tag { + if (out.request.page, out.request.tag) == asked { let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - self.texture = Some(ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR)); + self.texture = Some((page, asked.1, ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR))); } } if self.pending() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } - Some(self.texture.as_ref().map(|t| t.id())) + self.texture.as_ref().filter(|(p, ..)| *p == page).map(|(.., t)| t.id()) } } @@ -448,7 +451,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); - let picture = unmarked.unwrap_or_else(|| { + let picture = unmarked.or_else(|| { app.views.get_mut(i).and_then(|view| { view.need_thumbnail(target, true); view.thumb_id(target) diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 994e533a9d..e0d429efa6 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -325,13 +325,13 @@ fn preview_picture(h: &Harness<'static, PdfCraftApp>, uploads: &Uploads) -> Opti let egui::Shape::Mesh(m) = &s.shape else { return None }; let r = m.calc_bounds(); // The size of the 210-point box left of the label (which is centred down its side), and - // not the stepper's arrow beside the label. + // not the stepper's arrow beside the label. A landscape page is shorter than the box, so + // it can sit below the label: within half the box of it. let beside = r.width() > 50.0 && r.height() <= 215.0 && r.right() <= label.left() && label.left() - r.right() < 260.0 - && r.top() <= label.bottom() - && r.bottom() >= label.top(); + && (r.center().y - label.center().y).abs() <= 105.0; if !beside { return None; } @@ -386,6 +386,8 @@ fn update_previews_the_page_without_the_marks_it_replaces() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); + // The Pages panel keeps both thumbnails, for stepping to page 2 in Update below. + app.set_option("panel", "pages").unwrap(); app }); h.run_steps(4); @@ -425,17 +427,30 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.background.update")); h.run_steps(2); h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; - let (tint, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let white = |i: &egui::ColorImage| at(i, 0.5, 0.75) == egui::Color32::WHITE; + let (tint, page) = wait_for_preview(&mut h, &uploads, "the page without its background", white); assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); + // Stepping to page 2 shows its thumbnail until it renders without the background, not a + // blank sheet: the pages are prepared once, and each renders on its own. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().views[0].thumb(1).is_none() { + assert!(std::time::Instant::now() < deadline, "page 2's thumbnail never loaded"); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().marks_draft.preview = Some(1); + h.step(); + assert!(preview_picture(&h, &uploads).is_some(), "a page shows while page 2 renders"); + wait_for_preview(&mut h, &uploads, "page 2 without its background", white); h.get_by_label("Cancel").click(); h.run_steps(2); assert!(h.state_mut().execute("edit.watermark.update")); h.run_steps(2); h.state_mut().marks_draft.wm.text = "NEW".into(); - let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its watermark", |i| count(i, red) == 0); assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); h.get_by_label("Cancel").click(); @@ -443,7 +458,7 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.header_footer.update")); h.run_steps(2); - let (_, page) = wait_for_preview(&mut h, &uploads, "a page", |_| true); + let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its header", |i| count(i, blue) == 0); assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); } From 098a1f8321722ef194442f47479d6532eeca0dc6 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 12:40:45 +0100 Subject: [PATCH 51/75] fix(ui): Update draws the page shown without its marks at once, and a page that fails alone The preview showed the page's thumbnail, old marks and all, until the unmarked page rendered, and one page that failed to render switched the unmarked preview off for every page. The page shown is now drawn from the unmarked document straight away at the preview's size; the pages a step goes to render ahead off the UI thread; a page that fails falls back to its own thumbnail while the others keep theirs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 2 +- crates/ui-egui/src/dialogs/marks_ui.rs | 96 +++++++++++++++++--------- crates/ui-egui/tests/it/marks.rs | 26 +++---- 3 files changed, 74 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b1abddfdca..dfdb522158 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -307,7 +307,7 @@ impl PdfCraftApp { /// `true` while any open document still waits for page renders (used by headless capture). pub fn render_pending(&self) -> bool { - self.views.iter().any(|v| v.render_pending()) || self.unmarked_page.pending() + self.views.iter().any(|v| v.render_pending()) } } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 2efc10ecce..d9a5b8815d 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -3,6 +3,8 @@ //! page the range selects, with the marks laid out on it. "Update" opens the same dialog in //! replace mode. +use std::collections::{HashMap, HashSet}; + use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; @@ -186,31 +188,35 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { /// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still /// have them, so the new marks would be drawn over the old ones, and a new background colour /// would tint the old one. The document without them is made once per dialog (it costs about a -/// save), and each page shown is rendered from it off the UI thread, like the thumbnails. +/// save). The page shown is drawn from it straight away, at the preview's small size, so neither +/// a blank sheet nor the old marks show while it renders; the pages a step goes to next render +/// ahead, off the UI thread, so stepping rarely waits. #[derive(Default)] pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, + pages: Option, renderer: Option, - /// The page and scale last asked for. - asked: Option<(usize, u64)>, - /// The page and scale the texture shows. - texture: Option<(usize, u64, egui::TextureHandle)>, + /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the + /// pages next to it. + textures: HashMap, + /// Pages that failed to render: their thumbnails show instead, the other pages still don't. + failed: HashSet, + /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step + /// is a frame, which takes what has come back first and draws the rest itself. + ahead: Vec<(usize, u64)>, } impl UnmarkedPage { - pub(crate) fn pending(&self) -> bool { - self.renderer.is_some() && self.asked.is_some() && self.texture.as_ref().map(|(p, t, _)| (*p, *t)) != self.asked - } - /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: - /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` is - /// still rendering (its thumbnail shows until then, rather than a blank sheet). + /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` + /// fails to render. `near` are the pages a step goes to from it, rendered ahead. fn picture( &mut self, ctx: &egui::Context, (doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId), page: usize, + near: &[usize], kind: MarkKind, replace: bool, ) -> Option { @@ -218,34 +224,57 @@ impl UnmarkedPage { if self.key != key { *self = Self { key, ..Default::default() }; if replace { - self.renderer = doc.without_marks(kind).ok().flatten().map(|p| p.renderer()); + self.pages = doc.without_marks(kind).ok().flatten(); + self.renderer = self.pages.as_ref().map(|p| p.renderer()); } } - let renderer = self.renderer.as_ref()?; + let (pages, renderer) = (self.pages.as_ref()?, self.renderer.as_ref()?); // At the preview's size in pixels. - let p = doc.info.pages.get(page)?; - let scale = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); - let asked = (page, (scale * 1000.0) as u64); - if self.asked != Some(asked) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag: asked.1, ..Default::default() }]); - self.asked = Some(asked); - } - if let Some(out) = renderer.try_recv() { + let scale = |page: usize| { + let p = doc.info.pages.get(page)?; + let s = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); + Some((s, (s * 1000.0) as u64)) + }; + let load = |ctx: &egui::Context, out: pdfcraft_render::RenderedPage| { + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR) + }; + while let Some(out) = renderer.try_recv() { + let at = (out.request.page, out.request.tag); + self.ahead.retain(|a| *a != at); if out.error.is_some() { - // The thumbnails, as before, rather than no page at all. - self.renderer = None; - self.texture = None; - return None; - } - if (out.request.page, out.request.tag) == asked { - let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - self.texture = Some((page, asked.1, ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR))); + self.failed.insert(at.0); + } else { + self.textures.insert(at.0, (at.1, load(ctx, out))); } } - if self.pending() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + let (s, tag) = scale(page)?; + if !self.failed.contains(&page) && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) { + match pages.render_page(page, s) { + Ok(out) => { + self.textures.insert(page, (tag, load(ctx, out))); + } + Err(_) => { + self.failed.insert(page); + } + } } - self.texture.as_ref().filter(|(p, ..)| *p == page).map(|(.., t)| t.id()) + // Ahead: the pages a step goes to, not rendered yet at their scale. + let wanted: Vec<(usize, u64)> = near + .iter() + .filter(|p| !self.failed.contains(p)) + .filter_map(|&p| scale(p).map(|(_, t)| (p, t))) + .filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t)) + .collect(); + if !wanted.is_empty() && wanted != self.ahead { + let requests = wanted + .iter() + .filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() })); + renderer.set_queue(requests.collect()); + self.ahead = wanted; + } + self.textures.retain(|p, _| *p == page || near.contains(p)); + self.textures.get(&page).filter(|_| !self.failed.contains(&page)).map(|(_, t)| t.id()) } } @@ -450,7 +479,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M // frame the engine lays the marks out in. Update draws it without the marks it replaces. let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); - let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, kind, d.replace); + let near: Vec = [false, true].into_iter().filter_map(|forward| step_preview(&d.range, target, count, forward)).collect(); + let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, &near, kind, d.replace); let picture = unmarked.or_else(|| { app.views.get_mut(i).and_then(|view| { view.need_thumbnail(target, true); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index e0d429efa6..f66bf39864 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -386,8 +386,6 @@ fn update_previews_the_page_without_the_marks_it_replaces() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); - // The Pages panel keeps both thumbnails, for stepping to page 2 in Update below. - app.set_option("panel", "pages").unwrap(); app }); h.run_steps(4); @@ -427,30 +425,26 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.background.update")); h.run_steps(2); h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0]; - let white = |i: &egui::ColorImage| at(i, 0.5, 0.75) == egui::Color32::WHITE; - let (tint, page) = wait_for_preview(&mut h, &uploads, "the page without its background", white); + h.step(); + // Update shows the page without the marks from the frame it opens: neither a blank sheet nor + // the old marks while it renders. + let shown = |h: &Harness<'static, PdfCraftApp>| preview_picture(h, &uploads).expect("a page is previewed"); + let (tint, page) = shown(&h); assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page"); assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background"); assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay"); - // Stepping to page 2 shows its thumbnail until it renders without the background, not a - // blank sheet: the pages are prepared once, and each renders on its own. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - while h.state().views[0].thumb(1).is_none() { - assert!(std::time::Instant::now() < deadline, "page 2's thumbnail never loaded"); - h.step(); - std::thread::sleep(std::time::Duration::from_millis(5)); - } + // So does a step to page 2: the pages are prepared once, and each is drawn as it is shown. h.state_mut().marks_draft.preview = Some(1); h.step(); - assert!(preview_picture(&h, &uploads).is_some(), "a page shows while page 2 renders"); - wait_for_preview(&mut h, &uploads, "page 2 without its background", white); + assert_eq!(at(&shown(&h).1, 0.5, 0.75), egui::Color32::WHITE, "page 2 is previewed without its background at once"); h.get_by_label("Cancel").click(); h.run_steps(2); assert!(h.state_mut().execute("edit.watermark.update")); h.run_steps(2); h.state_mut().marks_draft.wm.text = "NEW".into(); - let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its watermark", |i| count(i, red) == 0); + h.step(); + let (_, page) = shown(&h); assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay"); h.get_by_label("Cancel").click(); @@ -458,7 +452,7 @@ fn update_previews_the_page_without_the_marks_it_replaces() { assert!(h.state_mut().execute("edit.header_footer.update")); h.run_steps(2); - let (_, page) = wait_for_preview(&mut h, &uploads, "the page without its header", |i| count(i, blue) == 0); + let (_, page) = shown(&h); assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one"); assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay"); } From dcec1cecfffbbb1bd425e7a41b0bd2abd190dff1 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 13:00:34 +0100 Subject: [PATCH 52/75] fix(ui): Update's preview parses its unmarked copy once and keeps a page's failure to itself The page shown was drawn through a fresh renderer each time, which parsed the whole unmarked document on the UI thread; UnmarkedPages now parses it once and keeps it. An error from rendering a page ahead (the pool retiring its parser, a cancelled request) no longer marks that page failed: only drawing it when shown decides, and a page that has a picture keeps it. On the web, where the pool renders on the UI thread, nothing renders ahead. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 3 +- crates/engine/src/text_preview.rs | 48 ++++++++++++++++++++++++-- crates/ui-egui/src/dialogs/marks_ui.rs | 25 +++++++++----- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c58ba90129..b21f3d335c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3023,7 +3023,7 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); - let unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); let page = unmarked.render_page(0, 1.0).unwrap(); assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); assert!(reds(&page) > 50, "the watermark stays"); @@ -3034,6 +3034,7 @@ fn update_previews_on_the_page_without_the_marks_it_replaces() { assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages"); assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); let doc = s.get(id).unwrap(); diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index 5f39945a14..f2e60dac07 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -27,6 +27,12 @@ impl TextEditPreview { pub struct UnmarkedPages { bytes: Arc>, config: RenderConfig, + /// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried + /// again), and kept: parsing costs about as much as reading the document. + parsed: Option, + /// How many times it was parsed. + #[cfg(test)] + builds: u64, } impl UnmarkedPages { @@ -34,8 +40,38 @@ impl UnmarkedPages { RenderPool::new(self.bytes.clone(), 1, self.config.clone()) } - pub fn render_page(&self, page: usize, scale: f32) -> Result { - render_one(&self.bytes, &self.config, page, scale) + /// `page` at `scale`, on this thread. + pub fn render_page(&mut self, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + if self.parsed.is_none() { + self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone())); + #[cfg(test)] + { + self.builds += 1; + } + } + let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) }; + let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() })); + let out = match out { + Ok(out) => out, + Err(e) => { + // A panic may have left it half-way: parse afresh next time. + self.parsed = None; + return Err(e); + } + }; + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + } + + /// How many times [`Self::render_page`] has parsed the document. + #[cfg(test)] + pub(crate) fn parser_builds(&self) -> u64 { + self.builds } } @@ -129,7 +165,13 @@ impl Document { return Ok(None); } let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(UnmarkedPages { bytes, config: self.config.clone() })) + Ok(Some(UnmarkedPages { + bytes, + config: self.config.clone(), + parsed: None, + #[cfg(test)] + builds: 0, + })) })? } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index d9a5b8815d..a3162d0851 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -190,7 +190,8 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { /// would tint the old one. The document without them is made once per dialog (it costs about a /// save). The page shown is drawn from it straight away, at the preview's small size, so neither /// a blank sheet nor the old marks show while it renders; the pages a step goes to next render -/// ahead, off the UI thread, so stepping rarely waits. +/// ahead, off the UI thread (not on the web, where the pool renders on it), so stepping rarely +/// waits. #[derive(Default)] pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. @@ -200,7 +201,8 @@ pub(crate) struct UnmarkedPage { /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the /// pages next to it. textures: HashMap, - /// Pages that failed to render: their thumbnails show instead, the other pages still don't. + /// Pages that failed to render when shown, so they aren't tried every frame: their + /// thumbnails show instead, the other pages still don't. failed: HashSet, /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step /// is a frame, which takes what has come back first and draws the rest itself. @@ -228,7 +230,10 @@ impl UnmarkedPage { self.renderer = self.pages.as_ref().map(|p| p.renderer()); } } - let (pages, renderer) = (self.pages.as_ref()?, self.renderer.as_ref()?); + // On the web the render pool renders inside `try_recv`, on this thread: rendering ahead + // there would draw the pages next to it in the same frame too. + let near = if cfg!(target_arch = "wasm32") { &[][..] } else { near }; + let renderer = self.renderer.as_ref()?; // At the preview's size in pixels. let scale = |page: usize| { let p = doc.info.pages.get(page)?; @@ -242,14 +247,17 @@ impl UnmarkedPage { while let Some(out) = renderer.try_recv() { let at = (out.request.page, out.request.tag); self.ahead.retain(|a| *a != at); - if out.error.is_some() { - self.failed.insert(at.0); - } else { + // An error rendering ahead needn't be the page's (the pool retires its parser, a + // request is cancelled): the page is drawn here when it is shown, and that decides. + if out.error.is_none() { self.textures.insert(at.0, (at.1, load(ctx, out))); } } let (s, tag) = scale(page)?; - if !self.failed.contains(&page) && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) { + if !self.failed.contains(&page) + && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) + && let Some(pages) = self.pages.as_mut() + { match pages.render_page(page, s) { Ok(out) => { self.textures.insert(page, (tag, load(ctx, out))); @@ -274,7 +282,8 @@ impl UnmarkedPage { self.ahead = wanted; } self.textures.retain(|p, _| *p == page || near.contains(p)); - self.textures.get(&page).filter(|_| !self.failed.contains(&page)).map(|(_, t)| t.id()) + // A picture rendered at another scale beats the thumbnail with the old marks. + self.textures.get(&page).map(|(_, t)| t.id()) } } From 1e239b0287183287094875939d8d7b39e6abe5d1 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 28 Oct 2026 13:29:11 +0100 Subject: [PATCH 53/75] fix(ui): a page that fails to render ahead isn't asked again every frame An error from rendering a neighbour ahead left it wanted and unqueued, so each frame queued it again. It is now kept out of rendering ahead at that scale (drawing it when shown still decides). On the web, where nothing renders ahead, no render pool is made; picture()'s doc says a page that fails keeps a picture drawn earlier. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/marks_ui.rs | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index a3162d0851..92af6279a2 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -197,6 +197,8 @@ pub(crate) struct UnmarkedPage { /// The document, its edit generation and the kind of mark taken away. key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, pages: Option, + /// Renders the pages next to the one shown, ahead. None on the web, where the pool renders on + /// the UI thread inside `try_recv`: it would draw them in the same frame too. renderer: Option, /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the /// pages next to it. @@ -207,12 +209,16 @@ pub(crate) struct UnmarkedPage { /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step /// is a frame, which takes what has come back first and draws the rest itself. ahead: Vec<(usize, u64)>, + /// Pages (at a scale) that came back from rendering ahead with an error, not asked ahead + /// again: the error needn't be the page's, so drawing it when shown still decides. + not_ahead: HashSet<(usize, u64)>, } impl UnmarkedPage { /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` - /// fails to render. `near` are the pages a step goes to from it, rendered ahead. + /// fails to render and was never drawn (a picture of it at another scale is shown rather than + /// the old marks). `near` are the pages a step goes to from it, rendered ahead. fn picture( &mut self, ctx: &egui::Context, @@ -227,13 +233,11 @@ impl UnmarkedPage { *self = Self { key, ..Default::default() }; if replace { self.pages = doc.without_marks(kind).ok().flatten(); - self.renderer = self.pages.as_ref().map(|p| p.renderer()); + self.renderer = self.pages.as_ref().filter(|_| !cfg!(target_arch = "wasm32")).map(|p| p.renderer()); } } - // On the web the render pool renders inside `try_recv`, on this thread: rendering ahead - // there would draw the pages next to it in the same frame too. - let near = if cfg!(target_arch = "wasm32") { &[][..] } else { near }; - let renderer = self.renderer.as_ref()?; + self.pages.as_ref()?; + let near = if self.renderer.is_some() { near } else { &[][..] }; // At the preview's size in pixels. let scale = |page: usize| { let p = doc.info.pages.get(page)?; @@ -244,13 +248,16 @@ impl UnmarkedPage { let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR) }; - while let Some(out) = renderer.try_recv() { + while let Some(out) = self.renderer.as_ref().and_then(|r| r.try_recv()) { let at = (out.request.page, out.request.tag); self.ahead.retain(|a| *a != at); // An error rendering ahead needn't be the page's (the pool retires its parser, a // request is cancelled): the page is drawn here when it is shown, and that decides. + // It isn't asked ahead again, so a page that always fails isn't rendered every frame. if out.error.is_none() { self.textures.insert(at.0, (at.1, load(ctx, out))); + } else { + self.not_ahead.insert(at); } } let (s, tag) = scale(page)?; @@ -272,9 +279,13 @@ impl UnmarkedPage { .iter() .filter(|p| !self.failed.contains(p)) .filter_map(|&p| scale(p).map(|(_, t)| (p, t))) + .filter(|at| !self.not_ahead.contains(at)) .filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t)) .collect(); - if !wanted.is_empty() && wanted != self.ahead { + if let Some(renderer) = self.renderer.as_ref() + && !wanted.is_empty() + && wanted != self.ahead + { let requests = wanted .iter() .filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() })); From aad8a3841cba715c0cbc258010a2de4faf3c8c90 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 11:37:10 -0400 Subject: [PATCH 54/75] M7: Type1 fonts with an OpenType-wrapped CFF program draw their own face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Type1 font whose FontFile3 has subtype /OpenType (ISO 32000-2 §9.9) keeps its CFF program in the `CFF ` table of an OpenType wrapper. CffFontBlob::new rejected the wrapper, so the font fell back to the standard-font stand-in and the text drew in the wrong face and weight. The CFF table is now taken from the wrapper, as composite fonts already do (hayro-interpret patch (69)). The test wraps the renderer's bundled CFF program in a one-table OTTO directory at run time (no font file added) and requires the wrapped and bare programs to give identical pixels; it fails with the fix reverted. A wrapper without a `CFF ` table still draws, with the stand-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/main.rs | 1 + .../tests/it/open_type_cff_simple_font.rs | 85 +++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/font/blob.rs | 7 ++ vendor/hayro-interpret/src/font/type1.rs | 8 +- 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/render/tests/it/open_type_cff_simple_font.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..900f9ecf15 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -5,6 +5,7 @@ mod inline_image_ei; mod jbig2_invariants; mod luminosity_group_space; mod named_form_properties; +mod open_type_cff_simple_font; mod search; mod separation_all; mod substitute_widths; diff --git a/crates/render/tests/it/open_type_cff_simple_font.rs b/crates/render/tests/it/open_type_cff_simple_font.rs new file mode 100644 index 0000000000..5cc9d8284a --- /dev/null +++ b/crates/render/tests/it/open_type_cff_simple_font.rs @@ -0,0 +1,85 @@ +//! A `Type1` font whose `FontFile3` has subtype `OpenType` (ISO 32000-2 §9.9) keeps its CFF +//! program inside an OpenType table directory; it is drawn with that program, not with a +//! standard-font stand-in (hayro-interpret patch (69)). +//! +//! The program is the renderer's own bundled fixed-pitch CFF face, so no font file is added. +//! Drawn right, the bare program and the same program in an OpenType wrapper give the same +//! pixels; the stand-in a serif font falls back to is a different face. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +const CFF: &[u8] = include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb"); + +/// `cff` in the smallest OpenType wrapper: an `OTTO` table directory with one `CFF ` table. +fn open_type(cff: &[u8]) -> Vec { + let mut out = b"OTTO".to_vec(); + out.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + out.extend_from_slice(b"CFF "); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&28u32.to_be_bytes()); + out.extend_from_slice(&u32::try_from(cff.len()).unwrap().to_be_bytes()); + out.extend_from_slice(cff); + out +} + +/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program`. +fn page(program: &[u8], subtype: &str) -> Vec { + let body = "BT /F1 60 Tf 10 30 Td (AgW) Tj ET"; + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Synthetic-Semibold /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 103 /Widths [600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120 /FontFile3 7 0 R >>".to_vec(), + ]; + let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + objects.push(stream); + + 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", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + 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 +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +#[test] +fn an_open_type_wrapped_cff_program_draws_like_the_bare_program() { + let bare = pixels(page(CFF, "Type1C")); + assert!(inked(&bare) > 200, "the bare CFF program drew no text"); + let wrapped = pixels(page(&open_type(CFF), "OpenType")); + assert!(wrapped == bare, "the OpenType-wrapped program drew a different face: the font fell back"); +} + +/// A wrapper with no `CFF ` table still draws, with the stand-in, rather than failing the page. +#[test] +fn a_wrapper_without_a_cff_table_falls_back() { + let mut broken = open_type(CFF); + broken.get_mut(12..16).unwrap().copy_from_slice(b"glyf"); + let rgba = pixels(page(&broken, "OpenType")); + assert!(inked(&rgba) > 200, "the stand-in drew no text"); +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..c166b04f40 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; (69) a `Type1` font whose `FontFile3` has subtype `OpenType` (ISO 32000-2 §9.9) is drawn with the CFF program in its `CFF ` table (`CffFontBlob::from_open_type`); the wrapper was rejected and the text drew in the standard-font stand-in | (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); (69) `it::open_type_cff_simple_font::{an_open_type_wrapped_cff_program_draws_like_the_bare_program, a_wrapper_without_a_cff_table_falls_back}` (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/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 43b540cc9d..0aa8da435d 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -170,6 +170,13 @@ impl CffFontBlob { self.font().num_glyphs() } + /// PdfCraft patch (69): the `CFF ` table of an OpenType font, for a `FontFile3` of + /// subtype `OpenType` (ISO 32000-2 §9.9) under a simple font. + pub(crate) fn from_open_type(data: &[u8]) -> Option { + let cff = FontRef::new(data).ok()?.cff().ok()?; + Self::new(Arc::new(cff.offset_data().as_ref().to_vec())) + } + pub(crate) fn is_cid(&self) -> bool { self.font().is_cid() } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 4ef5f953f4..cad88a5850 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -282,7 +282,13 @@ impl CffKind { fn new(dict: &Dict<'_>) -> Option { let descriptor = dict.get::>(FONT_DESC)?; let data = descriptor.get::>(FONT_FILE3)?; - let font = CffFontBlob::new(Arc::new(data.decoded().ok()?.to_vec()))?; + let bytes = data.decoded().ok()?.to_vec(); + // PdfCraft patch (69): an OpenType-wrapped program carries its CFF in a table. + let font = if bytes.starts_with(b"OTTO") { + CffFontBlob::from_open_type(&bytes)? + } else { + CffFontBlob::new(Arc::new(bytes))? + }; let (encoding, encodings) = read_encoding(dict); let (widths, missing_width) = read_widths(dict, &descriptor)?; From 45d95f1107014c81a3a49c3bc760bda3d90050f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:18 -0400 Subject: [PATCH 55/75] macOS packaging: retry stapler when Apple's CloudKit drops the connection Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 50701e0c16..ef1b7f6c70 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -127,8 +142,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/PdfCraft-notarize.zip" notarize "$WORK/PdfCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -178,8 +193,8 @@ fi hdiutil_retry detach detach "$MOUNT" >/dev/null if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi From 75360d1d7db06fed974f48c914d4c04132757187 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:26 -0400 Subject: [PATCH 56/75] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f8e8ac8cd..8df9cf6236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-update", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4241,7 +4241,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.14.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4252,7 +4252,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.14.0" +version = "0.15.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4285,7 +4285,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4303,11 +4303,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.14.0" +version = "0.15.0" [[package]] name = "pdfcraft-content" -version = "0.14.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4315,7 +4315,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "hayro-syntax", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.14.0" +version = "0.15.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.14.0" +version = "0.15.0" dependencies = [ "aes", "md-5", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.14.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4373,7 +4373,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-docpath", @@ -4414,14 +4414,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "proptest", @@ -4431,7 +4431,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.14.0" +version = "0.15.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4441,7 +4441,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.14.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4454,11 +4454,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.14.0" +version = "0.15.0" [[package]] name = "pdfcraft-js" -version = "0.14.0" +version = "0.15.0" dependencies = [ "boa_engine", "chrono", @@ -4466,7 +4466,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4479,14 +4479,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4497,7 +4497,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "pdfcraft-annot", @@ -4512,11 +4512,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.14.0" +version = "0.15.0" [[package]] name = "pdfcraft-organize" -version = "0.14.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4527,7 +4527,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-channel", "futures-lite", @@ -4537,7 +4537,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4548,7 +4548,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4560,7 +4560,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4576,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bytemuck", "flate2", @@ -4595,7 +4595,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.14.0" +version = "0.15.0" dependencies = [ "aes", "aws-lc-rs", @@ -4631,11 +4631,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.14.0" +version = "0.15.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4687,7 +4687,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -4699,7 +4699,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.14.0" +version = "0.15.0" dependencies = [ "base64 0.23.1", "image", @@ -4713,7 +4713,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.14.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index f86ebaa15f..436731c137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -25,39 +25,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.14.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.14.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.14.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.14.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.14.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" } -pdfcraft-model = { path = "crates/model", version = "0.14.0" } -pdfcraft-content = { path = "crates/content", version = "0.14.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.14.0" } -pdfcraft-print = { path = "crates/print", version = "0.14.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.14.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" } -pdfcraft-js = { path = "crates/js", version = "0.14.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.14.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" } -pdfcraft-export = { path = "crates/export", version = "0.14.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.14.0" } -pdfcraft-create = { path = "crates/create", version = "0.14.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.14.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" } -pdfcraft-render = { path = "crates/render", version = "0.14.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.14.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.14.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.15.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.15.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.15.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.15.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.15.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.15.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.15.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" } +pdfcraft-model = { path = "crates/model", version = "0.15.0" } +pdfcraft-content = { path = "crates/content", version = "0.15.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.15.0" } +pdfcraft-print = { path = "crates/print", version = "0.15.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.15.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" } +pdfcraft-js = { path = "crates/js", version = "0.15.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.15.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" } +pdfcraft-export = { path = "crates/export", version = "0.15.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.15.0" } +pdfcraft-create = { path = "crates/create", version = "0.15.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.15.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" } +pdfcraft-render = { path = "crates/render", version = "0.15.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.15.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.15.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false } # pdf hayro = "0.7.1" From 46282373fb341c96f7a52f6e85891500a9d9dffd Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 12:59:31 -0400 Subject: [PATCH 57/75] M7: the missing-CFF fallback test checks it draws the stand-in A wrapper without a `CFF ` table must draw exactly what the same font with no embedded program draws, and not the embedded program. The old check (some ink on the page) also passed if the program were read despite the missing table; with the lookup changed to take the first table whatever its tag, the new assertion fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- .../tests/it/open_type_cff_simple_font.rs | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/render/tests/it/open_type_cff_simple_font.rs b/crates/render/tests/it/open_type_cff_simple_font.rs index 5cc9d8284a..7770e591c8 100644 --- a/crates/render/tests/it/open_type_cff_simple_font.rs +++ b/crates/render/tests/it/open_type_cff_simple_font.rs @@ -23,8 +23,10 @@ fn open_type(cff: &[u8]) -> Vec { out } -/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program`. -fn page(program: &[u8], subtype: &str) -> Vec { +/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program` of +/// `subtype`, or which embeds no program when `None`. +fn page(program: Option<(&[u8], &str)>) -> Vec { + let file = if program.is_some() { " /FontFile3 7 0 R" } else { "" }; let body = "BT /F1 60 Tf 10 30 Td (AgW) Tj ET"; let mut objects: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), @@ -32,12 +34,14 @@ fn page(program: &[u8], subtype: &str) -> Vec { b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), b"<< /Type /Font /Subtype /Type1 /BaseFont /Synthetic-Semibold /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 103 /Widths [600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600] /FontDescriptor 6 0 R >>".to_vec(), - b"<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120 /FontFile3 7 0 R >>".to_vec(), + format!("<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120{file} >>").into_bytes(), ]; - let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes(); - stream.extend_from_slice(program); - stream.extend_from_slice(b"\nendstream"); - objects.push(stream); + if let Some((program, subtype)) = program { + let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + objects.push(stream); + } let mut bytes = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -69,17 +73,20 @@ fn inked(rgba: &[u8]) -> usize { #[test] fn an_open_type_wrapped_cff_program_draws_like_the_bare_program() { - let bare = pixels(page(CFF, "Type1C")); + let bare = pixels(page(Some((CFF, "Type1C")))); assert!(inked(&bare) > 200, "the bare CFF program drew no text"); - let wrapped = pixels(page(&open_type(CFF), "OpenType")); + let wrapped = pixels(page(Some((&open_type(CFF), "OpenType")))); assert!(wrapped == bare, "the OpenType-wrapped program drew a different face: the font fell back"); } -/// A wrapper with no `CFF ` table still draws, with the stand-in, rather than failing the page. +/// A wrapper with no `CFF ` table still draws, with the stand-in a font without a program +/// gets, rather than failing the page or reading the program anyway. #[test] fn a_wrapper_without_a_cff_table_falls_back() { let mut broken = open_type(CFF); broken.get_mut(12..16).unwrap().copy_from_slice(b"glyf"); - let rgba = pixels(page(&broken, "OpenType")); + let rgba = pixels(page(Some((&broken, "OpenType")))); assert!(inked(&rgba) > 200, "the stand-in drew no text"); + assert!(rgba == pixels(page(None)), "the broken wrapper drew another face than the stand-in"); + assert!(rgba != pixels(page(Some((CFF, "Type1C")))), "the broken wrapper drew the embedded program"); } From 67d4f181795c46d149cbc50b50799ddf125c3ddf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 13:17:18 -0400 Subject: [PATCH 58/75] Grant network access so update checks can reach GitHub (#5189) --- packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml | 7 ++++--- packaging/linux/flatpak/ai.storyteller.pdfcraft.yml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 10aedeeee0..91897d4f15 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -30,9 +30,10 @@ finish-args: # passed by path (the command line, Open Recent, "Save" next to the original) work there. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check - # for updates reports that GitHub is unreachable; the optional --control channel listens on - # 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing + # Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens + # on 127.0.0.1 inside the sandbox. + - --share=network + # Deliberately not granted: home/host filesystem access. No CUPS socket either: printing # runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print # reports that the spooler is unavailable until it goes through the print portal. modules: diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index 8ae41ae976..9e4579fc15 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -31,9 +31,10 @@ finish-args: # passed by path (the command line, Open Recent, "Save" next to the original) work there. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check - # for updates reports that GitHub is unreachable; the optional --control channel listens on - # 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing + # Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens + # on 127.0.0.1 inside the sandbox. + - --share=network + # Deliberately not granted: home/host filesystem access. No CUPS socket either: printing # runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print # reports that the spooler is unavailable until it goes through the print portal. build-options: From 05630b5f153813c1f9845bf9d082ea233bcae5d7 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 14:38:14 -0400 Subject: [PATCH 59/75] fix(render): a gray drawn with the Color blend mode is no longer drawn black vello_cpu 0.0.8's clip_color, shared by the nonseparable blend modes, divided 0 by 0 when a gray's equal components were rounded just below 0 or above 1 after set_lum. The NaN drew the pixel black at the source's alpha whatever the backdrop: nine gray levels under /BM /Color in an empty isolated group (0.96 g among them) rendered (0, 0, 0), and two more at half opacity came out wrong. Vendor vello_cpu 0.0.8 (Apache-2.0 OR MIT) with a backport of linebender/vello#2000, which clips only when the denominator is positive. No vello_cpu release carries the fix yet; drop the copy when one does. Tests in it::nonseparable_gray run every gray level. The two Color ramps fail with the unpatched blend.rs. The Luminosity ramp (black over each opaque gray, upstream's other case) passes either way here: pages use the u8 pipeline, and that case only failed in the f32 one; it stays as a guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 2 - Cargo.toml | 1 + crates/render/tests/it/main.rs | 1 + crates/render/tests/it/nonseparable_gray.rs | 96 ++ vendor/README.md | 1 + vendor/vello_cpu/.cargo_vcs_info.json | 6 + vendor/vello_cpu/CHANGELOG.md | 205 +++ vendor/vello_cpu/Cargo.lock | 456 +++++++ vendor/vello_cpu/Cargo.toml | 192 +++ vendor/vello_cpu/Cargo.toml.orig | 73 ++ vendor/vello_cpu/LICENSE-APACHE | 176 +++ vendor/vello_cpu/LICENSE-MIT | 25 + vendor/vello_cpu/README.md | 188 +++ vendor/vello_cpu/examples/basic.rs | 165 +++ vendor/vello_cpu/examples/clipping.rs | 134 ++ vendor/vello_cpu/examples/masking.rs | 100 ++ vendor/vello_cpu/examples/paints.rs | 145 +++ vendor/vello_cpu/src/dispatch/mod.rs | 95 ++ .../vello_cpu/src/dispatch/multi_threaded.rs | 900 +++++++++++++ .../src/dispatch/multi_threaded/cost.rs | 130 ++ .../src/dispatch/multi_threaded/worker.rs | 204 +++ .../vello_cpu/src/dispatch/single_threaded.rs | 922 +++++++++++++ vendor/vello_cpu/src/filter/drop_shadow.rs | 312 +++++ vendor/vello_cpu/src/filter/flood.rs | 89 ++ vendor/vello_cpu/src/filter/gaussian_blur.rs | 1147 +++++++++++++++++ vendor/vello_cpu/src/filter/mod.rs | 120 ++ vendor/vello_cpu/src/filter/offset.rs | 44 + vendor/vello_cpu/src/filter/shift.rs | 253 ++++ .../src/fine/common/gradient/linear.rs | 28 + .../vello_cpu/src/fine/common/gradient/mod.rs | 246 ++++ .../src/fine/common/gradient/radial.rs | 107 ++ .../src/fine/common/gradient/sweep.rs | 61 + vendor/vello_cpu/src/fine/common/image.rs | 568 ++++++++ vendor/vello_cpu/src/fine/common/mod.rs | 6 + .../src/fine/common/rounded_blurred_rect.rs | 249 ++++ vendor/vello_cpu/src/fine/highp/blend.rs | 310 +++++ vendor/vello_cpu/src/fine/highp/compose.rs | 171 +++ vendor/vello_cpu/src/fine/highp/mod.rs | 544 ++++++++ vendor/vello_cpu/src/fine/lowp/compose.rs | 161 +++ vendor/vello_cpu/src/fine/lowp/gradient.rs | 84 ++ vendor/vello_cpu/src/fine/lowp/image.rs | 259 ++++ vendor/vello_cpu/src/fine/lowp/mod.rs | 770 +++++++++++ vendor/vello_cpu/src/fine/mod.rs | 1127 ++++++++++++++++ vendor/vello_cpu/src/layer_manager.rs | 154 +++ vendor/vello_cpu/src/lib.rs | 186 +++ vendor/vello_cpu/src/region.rs | 243 ++++ vendor/vello_cpu/src/render.rs | 917 +++++++++++++ vendor/vello_cpu/src/text.rs | 498 +++++++ vendor/vello_cpu/src/text_debug.rs | 108 ++ vendor/vello_cpu/src/util.rs | 112 ++ 50 files changed, 13089 insertions(+), 2 deletions(-) create mode 100644 crates/render/tests/it/nonseparable_gray.rs create mode 100644 vendor/vello_cpu/.cargo_vcs_info.json create mode 100644 vendor/vello_cpu/CHANGELOG.md create mode 100644 vendor/vello_cpu/Cargo.lock create mode 100644 vendor/vello_cpu/Cargo.toml create mode 100644 vendor/vello_cpu/Cargo.toml.orig create mode 100644 vendor/vello_cpu/LICENSE-APACHE create mode 100644 vendor/vello_cpu/LICENSE-MIT create mode 100644 vendor/vello_cpu/README.md create mode 100644 vendor/vello_cpu/examples/basic.rs create mode 100644 vendor/vello_cpu/examples/clipping.rs create mode 100644 vendor/vello_cpu/examples/masking.rs create mode 100644 vendor/vello_cpu/examples/paints.rs create mode 100644 vendor/vello_cpu/src/dispatch/mod.rs create mode 100644 vendor/vello_cpu/src/dispatch/multi_threaded.rs create mode 100644 vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs create mode 100644 vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs create mode 100644 vendor/vello_cpu/src/dispatch/single_threaded.rs create mode 100644 vendor/vello_cpu/src/filter/drop_shadow.rs create mode 100644 vendor/vello_cpu/src/filter/flood.rs create mode 100644 vendor/vello_cpu/src/filter/gaussian_blur.rs create mode 100644 vendor/vello_cpu/src/filter/mod.rs create mode 100644 vendor/vello_cpu/src/filter/offset.rs create mode 100644 vendor/vello_cpu/src/filter/shift.rs create mode 100644 vendor/vello_cpu/src/fine/common/gradient/linear.rs create mode 100644 vendor/vello_cpu/src/fine/common/gradient/mod.rs create mode 100644 vendor/vello_cpu/src/fine/common/gradient/radial.rs create mode 100644 vendor/vello_cpu/src/fine/common/gradient/sweep.rs create mode 100644 vendor/vello_cpu/src/fine/common/image.rs create mode 100644 vendor/vello_cpu/src/fine/common/mod.rs create mode 100644 vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs create mode 100644 vendor/vello_cpu/src/fine/highp/blend.rs create mode 100644 vendor/vello_cpu/src/fine/highp/compose.rs create mode 100644 vendor/vello_cpu/src/fine/highp/mod.rs create mode 100644 vendor/vello_cpu/src/fine/lowp/compose.rs create mode 100644 vendor/vello_cpu/src/fine/lowp/gradient.rs create mode 100644 vendor/vello_cpu/src/fine/lowp/image.rs create mode 100644 vendor/vello_cpu/src/fine/lowp/mod.rs create mode 100644 vendor/vello_cpu/src/fine/mod.rs create mode 100644 vendor/vello_cpu/src/layer_manager.rs create mode 100644 vendor/vello_cpu/src/lib.rs create mode 100644 vendor/vello_cpu/src/region.rs create mode 100644 vendor/vello_cpu/src/render.rs create mode 100644 vendor/vello_cpu/src/text.rs create mode 100644 vendor/vello_cpu/src/text_debug.rs create mode 100644 vendor/vello_cpu/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 8df9cf6236..8763aaad5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7350,8 +7350,6 @@ dependencies = [ [[package]] name = "vello_cpu" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", "glifo 0.1.1", diff --git a/Cargo.toml b/Cargo.toml index 436731c137..64a3cf5b60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,6 +128,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } vello_common = { path = "vendor/vello_common" } +vello_cpu = { path = "vendor/vello_cpu" } # The egui and winit crates below are pinned to immutable storytold git revisions (egui # 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). # Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..b68e4a8da1 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -5,6 +5,7 @@ mod inline_image_ei; mod jbig2_invariants; mod luminosity_group_space; mod named_form_properties; +mod nonseparable_gray; mod search; mod separation_all; mod substitute_widths; diff --git a/crates/render/tests/it/nonseparable_gray.rs b/crates/render/tests/it/nonseparable_gray.rs new file mode 100644 index 0000000000..5a3a3d7a28 --- /dev/null +++ b/crates/render/tests/it/nonseparable_gray.rs @@ -0,0 +1,96 @@ +//! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's +//! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black +//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going black in +//! vello_cpu's f32 pipeline; pages render through the u8 pipeline, where that case already holds, +//! so its test here guards rather than reproduces. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +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 an isolated group holding a 16 × 16 grid of 6 pt cells, one per gray level, each +/// drawn by `cell(level, "x y 6 6 re")` with `/B` (`/BM /{mode} /ca {alpha}`) available, and +/// returns each level's centre pixel. +fn gray_ramp(mode: &str, alpha: f32, cell: impl Fn(u8, &str) -> String) -> Vec<(u8, [u8; 3])> { + let mut content = String::new(); + for level in 0..=255_u8 { + let (x, y) = (2 + u32::from(level % 16) * 6, 2 + u32::from(level / 16) * 6); + content.push_str(&cell(level, &format!("{x} {y} 6 6 re"))); + content.push('\n'); + } + 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 100] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>".to_string(), + stream("", "/Fm0 Do"), + stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Resources << /ExtGState << /B << /BM /{mode} /ca {alpha} >> >> >>" + ), + &content, + ), + ]; + 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(), "{:?}", page.error); + (0..=255_u8) + .map(|level| { + let (x, y) = (5 + u32::from(level % 16) * 6, 100 - 5 - u32::from(level / 16) * 6); + let offset = usize::try_from((y * page.width + x) * 4).unwrap(); + (level, [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2]]) + }) + .collect() +} + +fn gray(level: u8) -> f32 { + f32::from(level) / 255.0 +} + +fn assert_ramp(what: &str, got: &[(u8, [u8; 3])], expected: impl Fn(u8) -> f32) { + let wrong: Vec<_> = got.iter().filter(|(level, rgb)| rgb.iter().any(|&c| (f32::from(c) - expected(*level)).abs() > 2.0)).collect(); + assert!(wrong.is_empty(), "{what}: {} levels off, (level, pixel): {wrong:?}", wrong.len()); +} + +#[test] +fn every_gray_under_color_over_nothing_keeps_its_lightness() { + // With nothing beneath it the blend result is the source (§11.4.7): each gray at half + // opacity, over the white page. + let got = gray_ramp("Color", 0.5, |level, rect| format!("/B gs {} g {rect} f", gray(level))); + assert_ramp("Color", &got, |level| (f32::from(level) + 255.0) / 2.0); +} + +#[test] +fn black_under_luminosity_halves_every_opaque_gray() { + // B(Cb, Cs) = SetLum(Cb, Lum(Cs)) is black for a black source, so at half opacity the + // result is half the backdrop's gray. + let got = gray_ramp("Luminosity", 0.5, |level, rect| format!("q {} g {rect} f /B gs 0 g {rect} f Q", gray(level))); + assert_ramp("Luminosity", &got, |level| f32::from(level) / 2.0); +} + +#[test] +fn a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode() { + for mode in ["Color", "Luminosity", "Hue", "Saturation"] { + let got = gray_ramp(mode, 1.0, |level, rect| format!("/B gs {} g {rect} f", gray(level))); + assert_ramp(mode, &got, f32::from); + } +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..f10f9db539 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,7 @@ Other resource categories retain their existing parent-chain lookup. | 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_cpu | 0.0.8 | Apache-2.0 OR MIT | (1) backport of linebender/vello#2000 (merged upstream, not yet in a vello_cpu release): `clip_color` in `fine/highp/blend.rs`, shared by the nonseparable blend modes, clips only when the denominator is positive. After `set_lum` a gray has three equal components; when rounding left them just below 0 or above 1, `l - n` (or `x - l`) was 0 and the 0/0 NaN drew the pixel black at the source's alpha, whatever the backdrop, so nine gray levels drawn with `/BM /Color` into an empty group (96% among them) came out black. Drop this copy once pdfcraft moves to a vello_cpu release that carries the fix | (1) `it::nonseparable_gray::{every_gray_under_color_over_nothing_keeps_its_lightness, a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode}` (`cargo test -p pdfcraft-render --test it`) fail without the patch | | 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_cpu/.cargo_vcs_info.json b/vendor/vello_cpu/.cargo_vcs_info.json new file mode 100644 index 0000000000..706823d53d --- /dev/null +++ b/vendor/vello_cpu/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "875f324f21da93019cae9e8e61d4abfd69893206" + }, + "path_in_vcs": "sparse_strips/vello_cpu" +} \ No newline at end of file diff --git a/vendor/vello_cpu/CHANGELOG.md b/vendor/vello_cpu/CHANGELOG.md new file mode 100644 index 0000000000..ddb5608611 --- /dev/null +++ b/vendor/vello_cpu/CHANGELOG.md @@ -0,0 +1,205 @@ + + +# Changelog + +## [Unreleased] + +This release has an [MSRV][] of 1.88. + +## [0.0.8][] - 2026-05-15 + +This release has an [MSRV][] of 1.88. + +### Added + +- `render_decoration` on glyph run builders for rendering text decorations with skip-ink behavior. ([#1592][] by [@taj-p][]) +- Support for rendering VARC glyphs in text paths. ([#1594][] by [@nicoburns][], [@oscargus][]) +- Support for synthetic font emboldening in text glyph runs via `GlyphRunBuilder::font_embolden` and `glifo::FontEmbolden`. ([#1628][] by [@jrmoulton][]) + +### Changed + +- Migrated text rendering to `glifo`; rendering, glyph, and image APIs now use an explicit `Resources` object for persistent image and glyph caches. `RenderContext::render_to_buffer`, `RenderContext::render_to_pixmap`, `RenderContext::composite_to_pixmap_at_offset`, and `RenderContext::glyph_run` now take resources, and image registration moved from `RenderContext` to `Resources`. ([#1562][] by [@LaurenzV][]) +- Renamed image transparency hint APIs from `may_have_opacities` and `*_opacity_hint` to `may_have_transparency` and `*_transparency_hint`. ([#1613][] by [@upsuper][]) + +### Removed + +- Experimental `vello_api` integration and `api` module. ([#1602][] by [@waywardmonkeys][]) +- Support for recordings. + This decision was made due to a number of downsides that came with the implementation. + See the corresponding PR and Zulip thread for more information. ([#1611][] by [@LaurenzV][]) + +### Fixed + +- Rendering of rare degenerate radial gradients with undefined regions, which should render those regions transparent. ([#1529][] by [@LaurenzV][]) +- Incorrect rendering of filter layers containing nested clip layers. ([#1541][] by [@LaurenzV][]) +- x86 artifacts in bicubic image rendering caused by negative interpolated color values wrapping during SIMD `f32` to `u8` conversion. ([#1563][] by [@LaurenzV][]) +- Stroked glyph rendering when scaled glyph runs absorb the transform into the font size, preserving the expected stroke width. ([#1576][] by [@LaurenzV][]) +- COLR glyph rendering, including glyphs using non-default blend modes. ([#1584][] by [@LaurenzV][]) +- Rendering of inverted rectangles, including blurred rounded rectangles. ([#1589][] by [@LaurenzV][]) + +### Optimized + +- CPU fine rasterization performance through improved SIMD codegen. ([#1616][] by [@LaurenzV][]) + +## [0.0.7][] - 2026-03-24 + +This release has an [MSRV][] of 1.92. + +### Added + +- `composite_to_pixmap_at_offset` method to `RenderContext` for compositing at specific offsets within a larger pixmap. ([#1416][] by [@grebmeg][]) +- `ImageResolver` for resolving opaque image IDs at rasterization time. ([#1451][] by [@grebmeg][]) +- Support for image tinting. ([#1460][] by [@grebmeg][]) + +### Fixed + +- Rendering of blurred rounded rectangles with zero or very small blur standard deviations. ([#1422][] by [@tomcur][]) +- Off-by-one error in gaussian blur decimation filter. ([#1488][] by [@LaurenzV][]) +- Filter layers with zero clips. ([#1437][] by [@LaurenzV][]) + +### Optimized + +- Bilinear image sampling in the `RenderMode::OptimizeQuality` (`f32`) pipeline. ([#1343][] by [@tomcur][]) + +## [0.0.6][] - 2026-01-15 + +This release has an [MSRV][] of 1.88. + +### Added + +- Support for the "offset" filter. ([#1351] by [@waywardmonkeys]) + +### Changed + +- Breaking change: Updated Peniko to [v0.6.0](https://github.com/linebender/peniko/releases/tag/v0.6.0). ([#1349][] by [@DJMcNab][]) + - This also updates Kurbo to [v0.13.0](https://github.com/linebender/kurbo/releases/tag/v0.13.0). +- Upgraded Skrifa to v0.40.0. ([#1353][] by [@waywardmonkeys][]) +- Upgraded Hashbrown to v0.16.1. ([#1354][] by [@waywardmonkeys][]) +- Optimized image rendering for axis-aligned images. ([#1335][] by [@grebmeg][]) + +See also the [vello_hybrid 0.0.6](../vello_hybrid/CHANGELOG.md#006---2026-01-15) and [vello_common 0.0.6](../vello_common/CHANGELOG.md#006---2026-01-15) releases. + +## [0.0.5][] - 2026-01-08 + +This release has an [MSRV][] of 1.88. + +### Added + +- The `RenderContext` now has a `set_blend_mode` (and a corresponding `blend_mode` getter method) that can be used to support non-isolated blending. ([#1159][] by [@LaurenzV]) +- The `RenderContext` now contains a `push_clip_path` and `pop_clip_path` method for performing non-isolated clipping. ([#1203][] by [@LaurenzV]) +- Experimental support for image filter effects: ([#1286][] by [@grebmeg][]) + - New filter API methods on `RenderContext`: + - `set_filter_effect()` - Set a filter to be applied to subsequent drawing operations. + - `push_filter_layer()` - Create a new layer with a filter effect. + - `FilterEffect` trait providing both u8 and f32 precision variants for rendering across different backends. + - Gaussian Blur filter with configurable standard deviation and edge modes (`None`, `Mirror`, `Wrap`, `Duplicate`). + Uses an optimized decimated blur algorithm with automatic downsampling for performance. + - Drop Shadow filter with customizable offset, blur radius, and shadow color. + - Flood filter for solid color fills. +- A `set_mask` method to make it possible to mask rendered paths without inducing layer isolation. ([#1237][] by [@LaurenzV]) +- Support for conditionally disabling the u8 or f32 pipeline. ([#1294][] by [@nicoburns]) + +### Changed + +- Improved performance of rendering opaque images. ([#1327][] by [@grebmeg]) + +### Known Limitations + +- Filter effects currently support only single-primitive filters; filter graphs with multiple chained primitives are not yet supported. +- Multithreaded rendering is not supported for filter effects; filters are only applied in single-threaded mode. + +See also the [vello_hybrid 0.0.5](../vello_hybrid/CHANGELOG.md#005---2026-01-08) and [vello_common 0.0.5](../vello_common/CHANGELOG.md#005---2026-01-08) releases. + +## [0.0.4][] - 2025-10-17 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_hybrid 0.0.4](../vello_hybrid/CHANGELOG.md#004---2025-10-17) and [vello_common 0.0.4](../vello_common/CHANGELOG.md#004---2025-10-17) releases. + +## [0.0.3][] - 2025-10-04 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_common 0.0.3](../vello_common/CHANGELOG.md#003---2025-10-04) release. + +## [0.0.2][] - 2025-09-22 + +This release has an [MSRV][] of 1.85. + +No changelog was kept for this release. + +See also the [vello_common 0.0.2](../vello_common/CHANGELOG.md#002---2025-09-22) release. + +## [0.0.1][] - 2025-05-10 + +This release has an [MSRV][] of 1.85. + +This is the initial release. No changelog was kept for this release. + +See also the [vello_common 0.0.1](../vello_common/CHANGELOG.md#001---2025-05-10) release. + +[@DJMcNab]: https://github.com/DJMcNab +[@grebmeg]: https://github.com/grebmeg +[@jrmoulton]: https://github.com/jrmoulton +[@LaurenzV]: https://github.com/LaurenzV +[@nicoburns]: https://github.com/nicoburns +[@oscargus]: https://github.com/oscargus +[@taj-p]: https://github.com/taj-p +[@tomcur]: https://github.com/tomcur +[@upsuper]: https://github.com/upsuper +[@waywardmonkeys]: https://github.com/waywardmonkeys + +[#1159]: https://github.com/linebender/vello/pull/1159 +[#1203]: https://github.com/linebender/vello/pull/1203 +[#1237]: https://github.com/linebender/vello/pull/1237 +[#1286]: https://github.com/linebender/vello/pull/1286 +[#1294]: https://github.com/linebender/vello/pull/1294 +[#1327]: https://github.com/linebender/vello/pull/1327 +[#1335]: https://github.com/linebender/vello/pull/1335 +[#1343]: https://github.com/linebender/vello/pull/1343 +[#1349]: https://github.com/linebender/vello/pull/1349 +[#1351]: https://github.com/linebender/vello/pull/1351 +[#1353]: https://github.com/linebender/vello/pull/1353 +[#1354]: https://github.com/linebender/vello/pull/1354 +[#1416]: https://github.com/linebender/vello/pull/1416 +[#1422]: https://github.com/linebender/vello/pull/1422 +[#1437]: https://github.com/linebender/vello/pull/1437 +[#1451]: https://github.com/linebender/vello/pull/1451 +[#1460]: https://github.com/linebender/vello/pull/1460 +[#1488]: https://github.com/linebender/vello/pull/1488 +[#1529]: https://github.com/linebender/vello/pull/1529 +[#1541]: https://github.com/linebender/vello/pull/1541 +[#1562]: https://github.com/linebender/vello/pull/1562 +[#1563]: https://github.com/linebender/vello/pull/1563 +[#1576]: https://github.com/linebender/vello/pull/1576 +[#1584]: https://github.com/linebender/vello/pull/1584 +[#1589]: https://github.com/linebender/vello/pull/1589 +[#1592]: https://github.com/linebender/vello/pull/1592 +[#1594]: https://github.com/linebender/vello/pull/1594 +[#1602]: https://github.com/linebender/vello/pull/1602 +[#1611]: https://github.com/linebender/vello/pull/1611 +[#1613]: https://github.com/linebender/vello/pull/1613 +[#1616]: https://github.com/linebender/vello/pull/1616 +[#1628]: https://github.com/linebender/vello/pull/1628 + +[Unreleased]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.8...HEAD +[0.0.8]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.7...sparse-strips-v0.0.8 +[0.0.7]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.6...sparse-strips-v0.0.7 +[0.0.6]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.5...sparse-strips-v0.0.6 +[0.0.5]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.4...sparse-strips-v0.0.5 +[0.0.4]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.3...sparse-strips-v0.0.4 +[0.0.3]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.2...sparse-strips-v0.0.3 +[0.0.2]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.1...sparse-stips-v0.0.2 +[0.0.1]: https://github.com/linebender/vello/compare/ca6b1e4c7f5b0d95953c3b524f5d3952d5669c5a...sparse-strips-v0.0.1 + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/vendor/vello_cpu/Cargo.lock b/vendor/vello_cpu/Cargo.lock new file mode 100644 index 0000000000..adebc31450 --- /dev/null +++ b/vendor/vello_cpu/Cargo.lock @@ -0,0 +1,456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", + "libm", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76258897e51fd156ee03b6246ea53f3e0eb395d0b327e9961c4fc4c8b2fa151a" +dependencies = [ + "libm", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "glifo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9d48c6d81526ad2f9d5d6e5fddf5f6949ffbc46fcac0db0d061a6e65097019" +dependencies = [ + "bytemuck", + "core_maths", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "libm", + "polycool", + "smallvec", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", + "libm", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "core_maths", + "read-fonts", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "libm", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +dependencies = [ + "bytemuck", + "crossbeam-channel", + "glifo", + "hashbrown", + "ordered-channel", + "png", + "rayon", + "thread_local", + "vello_common", +] diff --git a/vendor/vello_cpu/Cargo.toml b/vendor/vello_cpu/Cargo.toml new file mode 100644 index 0000000000..bce9113072 --- /dev/null +++ b/vendor/vello_cpu/Cargo.toml @@ -0,0 +1,192 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88" +name = "vello_cpu" +version = "0.0.8" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A CPU-based renderer for Vello, optimized for SIMD and multithreaded execution." +readme = "README.md" +keywords = [ + "2d", + "vector-graphics", +] +categories = [ + "rendering", + "graphics", +] +license = "Apache-2.0 OR MIT" +repository = "https://github.com/linebender/vello" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[features] +default = [ + "std", + "png", + "text", + "u8_pipeline", +] +f32_pipeline = [] +libm = [ + "vello_common/libm", + "glifo?/libm", +] +multithreading = [ + "std", + "dep:rayon", + "dep:thread_local", + "dep:ordered-channel", + "dep:crossbeam-channel", + "vello_common/multithreading", +] +png = [ + "dep:png", + "vello_common/png", + "glifo?/png", +] +std = [ + "vello_common/std", + "glifo?/std", +] +text = ["dep:glifo"] +u8_pipeline = [] + +[lib] +name = "vello_cpu" +path = "src/lib.rs" + +[[example]] +name = "basic" +path = "examples/basic.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "clipping" +path = "examples/clipping.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "masking" +path = "examples/masking.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "paints" +path = "examples/paints.rs" +required-features = [ + "png", + "std", +] + +[dependencies.bytemuck] +version = "1.25.0" +features = ["derive"] + +[dependencies.crossbeam-channel] +version = "0.5.15" +optional = true + +[dependencies.glifo] +version = "0.1.0" +optional = true +default-features = false + +[dependencies.hashbrown] +version = "0.17.1" +features = ["default-hasher"] +default-features = false + +[dependencies.ordered-channel] +version = "1.2.0" +features = [ + "crossbeam-channel", + "crossbeam-channel", +] +optional = true + +[dependencies.png] +version = "0.18.1" +optional = true + +[dependencies.rayon] +version = "1.12.0" +optional = true + +[dependencies.thread_local] +version = "1.1.9" +optional = true + +[dependencies.vello_common] +version = "0.0.8" +default-features = false + +[lints.clippy] +allow_attributes_without_reason = "warn" +cargo_common_metadata = "warn" +cast_possible_truncation = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_fields_in_debug = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +should_panic_without_expect = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/vello_cpu/Cargo.toml.orig b/vendor/vello_cpu/Cargo.toml.orig new file mode 100644 index 0000000000..534ae2db62 --- /dev/null +++ b/vendor/vello_cpu/Cargo.toml.orig @@ -0,0 +1,73 @@ +[package] +name = "vello_cpu" +# When moving past 0.0.x, also update caveats in the README +version = "0.0.8" +description = "A CPU-based renderer for Vello, optimized for SIMD and multithreaded execution." +categories = ["rendering", "graphics"] +keywords = ["2d", "vector-graphics"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +all-features = true +# There are no platform specific docs. +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[dependencies] +bytemuck = { workspace = true } +glifo = { workspace = true, default-features = false, optional = true } +vello_common = { workspace = true } +hashbrown = { workspace = true } +png = { workspace = true, optional = true } +crossbeam-channel = { workspace = true, optional = true } +ordered-channel = { workspace = true, optional = true, features = ["crossbeam-channel"] } +rayon = { workspace = true, optional = true } +thread_local = { workspace = true, optional = true } + +[features] +# If adding new features, also add to `src/lib.rs` +default = ["std", "png", "text", "u8_pipeline"] +# Get floating point functions from the standard library (likely using your target’s libc). +std = ["vello_common/std", "glifo?/std"] +# Use floating point implementations from libm. +libm = ["vello_common/libm", "glifo?/libm"] +# Allow loading Pixmap from PNG, and drawing png glyphs. +png = ["dep:png", "vello_common/png", "glifo?/png"] +# Enable multi-threaded rendering. +multithreading = [ + "std", + "dep:rayon", + "dep:thread_local", + "dep:ordered-channel", + "dep:crossbeam-channel", + "vello_common/multithreading", +] +# Add support for text rendering +text = ["dep:glifo"] + +# Speed focussed rendering using u8 math +u8_pipeline = [] +# Quality focussed rendering using f32 math +f32_pipeline = [] + +[lints] +workspace = true + +[[example]] +name = "basic" +required-features = ["png", "std"] + +[[example]] +name = "clipping" +required-features = ["png", "std"] + +[[example]] +name = "masking" +required-features = ["png", "std"] + +[[example]] +name = "paints" +required-features = ["png", "std"] diff --git a/vendor/vello_cpu/LICENSE-APACHE b/vendor/vello_cpu/LICENSE-APACHE new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/vendor/vello_cpu/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/vendor/vello_cpu/LICENSE-MIT b/vendor/vello_cpu/LICENSE-MIT new file mode 100644 index 0000000000..48ff8e414b --- /dev/null +++ b/vendor/vello_cpu/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2020 the Vello Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/vello_cpu/README.md b/vendor/vello_cpu/README.md new file mode 100644 index 0000000000..2a78b88629 --- /dev/null +++ b/vendor/vello_cpu/README.md @@ -0,0 +1,188 @@ +
+ +# Vello CPU + +**CPU-based renderer** + +[![Latest published version.](https://img.shields.io/crates/v/vello_cpu.svg)](https://crates.io/crates/vello_cpu) +[![Documentation build status.](https://img.shields.io/docsrs/vello_cpu.svg)](https://docs.rs/vello_cpu) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +\ +[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23vello-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/197075-vello) +[![GitHub Actions CI status.](https://img.shields.io/github/actions/workflow/status/linebender/vello/ci.yml?logo=github&label=CI)](https://github.com/linebender/vello/actions) +[![Dependency staleness status.](https://deps.rs/crate/vello_cpu/latest/status.svg)](https://deps.rs/crate/vello_cpu) + +
+ + + + + +[`RenderContext`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html +[RenderContext::set_paint]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.set_paint +[RenderContext::fill_path]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.fill_path +[RenderContext::stroke_path]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.stroke_path +[RenderContext::glyph_run]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.glyph_run +[RenderMode::OptimizeSpeed]: https://docs.rs/vello_cpu/latest/vello_cpu/enum.RenderMode.html#variant.OptimizeSpeed +[RenderMode::OptimizeQuality]: https://docs.rs/vello_cpu/latest/vello_cpu/enum.RenderMode.html#variant.OptimizeQuality +[`RenderContext::render_to_pixmap`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.render_to_pixmap +[`Pixmap`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.Pixmap.html + + + +Vello CPU is a 2D graphics rendering engine written in Rust, for devices with no or underpowered GPUs. + +We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +Vello CPU is being developed as part of work to address shortcomings in Vello. + +## Usage + +To use Vello CPU, you need to: + +- Create a [`RenderContext`][], a 2D drawing context for a fixed-size target area. +- For each object in your scene: + - Set how the object will be painted, using [`set_paint`][RenderContext::set_paint]. + - Set the shape to be drawn for that object, using methods like [`fill_path`][RenderContext::fill_path], + [`stroke_path`][RenderContext::stroke_path], or [`glyph_run`][RenderContext::glyph_run]. +- Render it to an image using [`RenderContext::render_to_pixmap`][]. + +```rust +use vello_cpu::{RenderContext, Resources, Pixmap, RenderMode}; +use vello_cpu::{color::{palette::css, PremulRgba8}, kurbo::Rect}; +let width = 10; +let height = 5; +let mut context = RenderContext::new(width, height); +let mut resources = Resources::new(); +context.set_paint(css::MAGENTA); +context.fill_rect(&Rect::from_points((3., 1.), (7., 4.))); + +let mut target = Pixmap::new(width, height); +// While calling `flush` is only strictly necessary if you are rendering using +// multiple threads, it is recommended to always do this. +context.flush(); +context.render_to_pixmap(&mut resources, &mut target); + +let expected_render = b"\ + 0000000000\ + 0001111000\ + 0001111000\ + 0001111000\ + 0000000000"; +let magenta = css::MAGENTA.premultiply().to_rgba8(); +let transparent = PremulRgba8 {r: 0, g: 0, b: 0, a: 0}; +let mut result = Vec::new(); +for pixel in target.data() { + if *pixel == magenta { + result.push(b'1'); + } else if *pixel == transparent { + result.push(b'0'); + } else { + panic!("Got unexpected pixel value {pixel:?}"); + } +} +assert_eq!(&result, expected_render); +``` + +Feel free to take a look at some further +[examples](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) +to better understand how to interact with Vello CPU's API, + +## Features + +- `std` (enabled by default): Get floating point functions from the standard library + (likely using your target's libc). +- `libm`: Use floating point implementations from [libm][]. +- `png`(enabled by default): Allow loading [`Pixmap`]s from PNG images. + Also required for rendering glyphs with an embedded PNG. Implies `std`. +- `multithreading`: Enable multi-threaded rendering. Implies `std`. +- `text` (enabled by default): Enables glyph rendering ([`glyph_run`][RenderContext::glyph_run]). +- `u8_pipeline` (enabled by default): Enable the u8 pipeline, for speed focused rendering using u8 math. + The `u8` pipeline will be used for [`OptimizeSpeed`][RenderMode::OptimizeSpeed], if both pipelines are enabled. + If you're using Vello CPU for application rendering, you should prefer this pipeline. +- `f32_pipeline`: Enable the `f32` pipeline, which is slower but has more accurate + results. This is espectially useful for rendering test snapshots. + The `f32` pipeline will be used for [`OptimizeQuality`][RenderMode::OptimizeQuality], if both pipelines are enabled. + +At least one of `std` and `libm` is required; `std` overrides `libm`. +At least one of `u8_pipeline` and `f32_pipeline` must be enabled. +You might choose to disable one of these pipelines if your application +won't use it, so as to reduce binary size. + +## Caveats + +Overall, Vello CPU is already very feature-rich and should be ready for +production use cases. The main caveat at the moment is that the API is +still likely to change and not stable yet. For example, we have +known plans to change the API around how image resources are used. + +Additionally, there are certain APIs that are still very much experimental, +including for example support for filters. This will be reflected in the +documentation of those APIs. + +Another caveat is that multi-threading with large thread counts +(more than 4) might give diminishing returns, especially when +making heavy use of layers and clip paths. + +## Performance + +Performance benchmarks can be found [here](https://laurenzv.github.io/vello_chart/), +As can be seen, Vello CPU achieves compelling performance on both, +aarch64 and x86 platforms. We also have SIMD optimizations for WASM SIMD, +meaning that you can expect good performance there as well. + +## Implementation + +If you want to gain a better understanding of Vello CPU and the +sparse strips paradigm, you can take a look at the [accompanying +master's thesis](https://ethz.ch/content/dam/ethz/special-interest/infk/inst-pls/plf-dam/documents/StudentProjects/MasterTheses/2025-Laurenz-Thesis.pdf) +that was written on the topic. Note that parts of the descriptions might +become outdated as the implementation changes, but it should give a good +overview nevertheless. + + +[libm]: https://crates.io/crates/libm + + + +## Minimum supported Rust Version (MSRV) + +This version of Vello CPU has been verified to compile with **Rust 1.92** and later. + +Future versions of Vello CPU might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Vello CPU's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +Discussion of Vello CPU development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#vello channel](https://xi.zulipchat.com/#narrow/channel/197075-vello). +All public content can be read without logging in. + +Contributions are welcome by pull request. +The [Rust code of conduct] applies. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct diff --git a/vendor/vello_cpu/examples/basic.rs b/vendor/vello_cpu/examples/basic.rs new file mode 100644 index 0000000000..54dc7f368c --- /dev/null +++ b/vendor/vello_cpu/examples/basic.rs @@ -0,0 +1,165 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! This example demonstrates the most basic usage of Vello CPU, including +//! fundamental features in 2D rendering like filling, stroking and applying +//! transforms. + +use vello_cpu::color::palette::css::YELLOW; +use vello_cpu::kurbo::Affine; +use vello_cpu::{ + Level, Pixmap, RenderContext, RenderMode, RenderSettings, Resources, + color::palette::css::{BLUE, GREEN, RED}, + kurbo::{Circle, Rect, Shape}, +}; + +fn main() { + // Vello CPU is a CPU-based 2D renderer. It takes drawing commands like + // "fill a rectangle" or "stroke a triangle" as input and rasterizes + // them into a bitmap with premultiplied RGBA pixels, which can then + // be further processed (for example by converting to PNG or displaying + // the result in a window). + + // We first need to define some basic render settings. + let settings = RenderSettings { + // The `level` field indicates what SIMD level should be used. In + // the vast majority of cases, you should just use `Level::new` so that + // Vello CPU automatically uses an appropriate level that is + // available on the host system. + // + // There are very few reasons to override that value. One instance where + // it might be useful to override is for example when using Vello CPU + // to create reference images for test suites. In that case, you could + // pass `Level::fallback`, which indicates to Vello CPU that no + // platform-specific SIMD intrinsics should be used. This might be useful + // because it reduces the possibility of slight pixel differences when + // running on different platforms. + level: Level::new(), + // The number of additional threads that should be used for rendering. + // This setting only has an effect when the `multi-threading` feature + // is enabled. More threads _usually_ translates to better performance, + // but also higher CPU usage. Multi-threading can be very effective + // in situations where you need to continually redraw scenes (for example + // when rendering GUIs), but is usually less useful for one-time + // rendering operations. It should also be noted that the current + // implementation of multi-threading is still not fully optimized and + // might not work as well in certain workloads, so it's worth trying + // yourself whether it actually leads to any speedups before using it. + // + // If you enabled the multi-threading feature and leave this as 0, + // it has the exact same effect as rendering in single-threaded mode. + // According to our experiments, 2-4 threads give the best results, + // using 4+ threads might result in diminishing results, depending on + // the workload. + num_threads: 0, + // Define whether the renderer should prioritize speed or quality + // during rendering. Currently, the only difference is that + // `OptimizeSpeed` will use u8/u16 for the rasterization + // while `OptimizeQuality` uses f32. The former is much faster, but + // has the disadvantage that the overall color accuracy might be slightly + // worse due to quantization. Unless you really care about that, it is + // highly recommended to use the `OptimizeSpeed` rendering mode. + render_mode: RenderMode::OptimizeSpeed, + }; + + // Vello CPU embraces a slightly different paradigm than a lot of other 2D + // renderers. Many 2D renderers use _immediate mode rendering_, where + // you create a single `Pixmap` and then dispatch rendering commands into + // it. In Vello CPU, there are two components instead. + // + // The first component is the `RenderContext`, which can be thought of as + // a reusable buffer for dispatching rendering commands. The `reusable` + // part is important: In situations where you are executing multiple + // rendering passes at the same resolution, you should reset the context + // and reuse it instead of always creating a new one, as will be shown below. + // This is important because it allows Vello CPU to reuse existing memory + // allocations, leading to better performance. + // + // The second component is then the `Pixmap`, which simply acts as a storage + // for the raw RGBA pixels from the render context. + + // Let's start by creating a new render context with a certain + // width and height in pixels, as well as our render settings. + let mut ctx = RenderContext::new_with(100, 100, settings); + // Vello CPU needs this to track certain resources (e.g. for glyph caching) + // across multiple frames. You need to make sure that you create on such struct + // for each render context you create, and then only use it in combination with that + // specific render context. + let mut resources = Resources::new(); + + // Vello CPU uses a Postscript-like API, where you can use methods like + // `set_paint` or `set_stroke` to update an internal state, and then + // dispatch commands to fill or stroke paths, using the current state. + + // Using the `set_paint` method, we can change what color our shapes should + // be drawn with. You can use any RGBA color for that. Apart from that, you + // can also paint your shapes using gradients or patterns (see the `paints` + // example). In this case, we are setting the color to blue. + ctx.set_paint(BLUE); + // Now, we can dispatch a commands, for example to fill a rectangle with + // the given dimensions. + ctx.fill_path(&Rect::new(25.0, 25.0, 75.0, 75.0).to_path(0.1)); + + // We can then update the color to a red with 50% opacity... + ctx.set_paint(RED.with_alpha(0.5)); + // ...and draw a different rectangle that overlaps the previous one. You + // can also use the `fill_rect` convenience method for that. + ctx.fill_rect(&Rect::new(50.0, 50.0, 85.0, 85.0)); + + ctx.set_paint(GREEN); + // As mentioned, stroking is also supported. You can update the stroke + // properties using the `set_stroke` method. + ctx.stroke_path(&Circle::new((50.0, 50.0), 30.0).to_path(0.1)); + + // Let's say that we have drawn everything we wanted to now. Next, it is + // recommended that you call the `flush` method. In theory, this call + // is only necessary when using multi-threaded rendering, to signal that + // no more operations will be dispatched and the render context should be + // synchronized. If you forget to call this during multi-threaded rendering, + // the application will panic. In single-threaded rendering, nothing happens. + // + // However, it is highly recommended that you always call this. + // This way, downstream consumers of your crate that might have externally + // enabled Vello CPU's multi-threading feature won't run into panics when + // running your code. + ctx.flush(); + + // Now the second step is to copy the results of the render context into the + // pixmap. We do this by creating a new pixmap (or reusing an existing one). + // Please note that the pixmap and the render context need to have the same + // dimensions! Otherwise, the renderer will panic. + let mut pixmap_1 = Pixmap::new(100, 100); + // Now, simply extract the results from the render context into the + // pixmap. + ctx.render_to_pixmap(&mut resources, &mut pixmap_1); + + // Now you can do whatever you want with the pixmap, which provides raw + // access to the premultiplied RGBA pixels of the image. If you have enabled + // the `png` feature, you can convert it into a PNG image very easily and + // then save it to disk. + let png_1 = pixmap_1.into_png().unwrap(); + std::fs::write("example_basic1.png", png_1).unwrap(); + + // If you have another scene you want to draw at the same resolution, + // you can simply reuse the existing render context instead of creating + // a new one. + ctx.reset(); + + // Vello CPU supports arbitrary affine transformations. + ctx.set_transform(Affine::scale(3.0)); + ctx.set_paint(YELLOW); + // The rectangle will now actually have the dimensions 60x60 since we + // applied an affine transform that scales everything by 3x to the context. + ctx.fill_rect(&Rect::new(0.0, 0.0, 20.0, 20.0)); + ctx.flush(); + + // Once again, we render the results into a pixmap again. If you can, + // you can just reuse existing pixmaps (assuming they have the correct + // dimension), since all previous pixels in the pixmap will be + // discarded. In our case, we need to create a new one since our call + // to `into_png` consumed the pixmap. + let mut pixmap_2 = Pixmap::new(100, 100); + ctx.render_to_pixmap(&mut resources, &mut pixmap_2); + let png_2 = pixmap_2.into_png().unwrap(); + std::fs::write("example_basic2.png", png_2).unwrap(); +} diff --git a/vendor/vello_cpu/examples/clipping.rs b/vendor/vello_cpu/examples/clipping.rs new file mode 100644 index 0000000000..a9b4badc85 --- /dev/null +++ b/vendor/vello_cpu/examples/clipping.rs @@ -0,0 +1,134 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Applying clip paths using Vello CPU. + +use vello_cpu::Pixmap; +use vello_cpu::RenderContext; +use vello_cpu::Resources; +use vello_cpu::color::palette::css::{BLUE, RED, WHITE}; +use vello_cpu::kurbo::{Circle, Rect, Shape}; + +fn main() { + // Clip-paths are a fundamental operation in 2D rendering and allow you + // to constrain the visible areas of all subsequently drawn paths to the + // shape of another area. Vello CPU has full support for them and actually + // provides 2 different ways of using them. Below, we will explore them + // and explain their difference and when to use which one. + + let mut ctx = RenderContext::new(200, 200); + + // Two example clip shapes. They have a small overlap in the center that forms + // an ellipse. + let clip_1 = Circle::new((75.0, 75.0), 50.0).to_path(0.1); + let clip_2 = Circle::new((125.0, 75.0), 50.0).to_path(0.1); + + // Method 1: Non-isolated clipping using the `push_clip_path` and + // `pop_clip_path` methods: + { + // Let's first create a white background. + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // As mentioned, clip paths contain the area that will be affected by + // our drawing operations. By default, drawing operations will be + // visible on the whole width/height of the render context. + + // After this first `push_clip_path`, all drawing operations will be + // constrained to the area of the first circle. + ctx.push_clip_path(&clip_1.to_path(0.1)); + // Clip paths can be nested/stacked to arbitrary depths. By + // nesting clip paths, the drawing area will be further reduced to + // the _intersection_ of all clip paths that are currently in-place. + // Thus, after this second `push_clip_path` call, only the pixels + // that lie in the intersection of both circles will be painted. + ctx.push_clip_path(&clip_2.to_path(0.1)); + ctx.set_paint(RED); + // Even though the rectangle covers the whole viewport, only the parts + // that lie in the intersection of both circles will be painted. + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + // By popping a clip path, the top clip-path on the element (in this case + // `clip_2`) will be removed. Thus, only `clip_1` remains in-place. + ctx.pop_clip_path(); + ctx.set_paint(BLUE.with_alpha(0.2)); + // This rectangle will only be constrained by the area of `clip_1`. + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // This is optional. You don't strictly have to pop all clip paths + // currently in-place before rasterizing to the pixmap. + ctx.pop_clip_path(); + + ctx.flush(); + + save_pixmap(&ctx, "example_clipping1"); + } + + // Method 2: Isolated clipping using the `push_clip_layer` and `pop_layer` + // methods: + { + // Overall, this method works exactly the same as the previous + // one, just that the method calls are different. Instead of + // `push_clip_path`, we have `push_clip_layer`, and instead of + // `pop_clip_path`, we have `pop_clip_layer`. + + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + ctx.push_clip_layer(&clip_1.to_path(0.1)); + ctx.push_clip_layer(&clip_2.to_path(0.1)); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + ctx.pop_layer(); + ctx.set_paint(BLUE.with_alpha(0.2)); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // Unlike the first method, THIS PART IS NOT OPTIONAL! Before + // rasterizing, you need to make sure that all previously pushed layers + // have been popped. Otherwise, the renderer will panic. + ctx.pop_layer(); + + ctx.flush(); + save_pixmap(&ctx, "example_clipping2"); + } + + // If you inspect the above results, you will see that they visually yield + // the same result. So what is their difference and when should you use + // which one? The answer should become clearer when explaining how they + // differ conceptually. + // When creating a clip path using `push_clip_path`, every subsequent drawing + // operation will conceptually be stencil-masked through the intersection + // of all currently active clip paths before being drawn onto the screen. + // On the other hand, doing `push_clip_layer` will actually push a whole + // new isolated layer, and once you call + // `pop_layer`, the layer _as a whole_ will be clipped to the bounds of the + // paths and composited back into the previous layer. + // + // Which one of these two methods you should use depend on the imaging model + // you are trying to reflect. For example, in SVG, each group with a clip-path + // automatically requires creating a new isolated layer. In this case, the + // isolated clipping method fits the imaging model better. On the other hand, + // in PDF for example, clip paths and layer isolation are two completely + // separate concepts. Therefore, it makes much more sense to use the + // `push_clip_path` method, since you don't want to introduce an isolated + // layer each time a new clip path is added. + // + // Finally, it is also worth mentioning that according to your experiments, + // non-isolated clipping is usually faster than isolated clipping, especially + // on the CPU. Therefore, if you are still in doubt, it is recommended + // that you simply use the non-isolated method. If necessary, you can easily + // just mix the two different methods as well. + // + // Another small note: Clip paths can actually be emulated using alpha + // masks (see the masking example), so strictly speaking you don't need + // to use the specialized clipping methods to create clip paths. However, + // the clipping methods are _much faster_ than masking, and you should + // therefore always prefer using those over masking. +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} diff --git a/vendor/vello_cpu/examples/masking.rs b/vendor/vello_cpu/examples/masking.rs new file mode 100644 index 0000000000..9f7b310138 --- /dev/null +++ b/vendor/vello_cpu/examples/masking.rs @@ -0,0 +1,100 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Using masks with Vello CPU. + +use vello_cpu::color::palette::css::{BLUE, RED, WHITE}; +use vello_cpu::kurbo::Rect; +use vello_cpu::{Mask, Pixmap, RenderContext, Resources}; + +const SIZE: u16 = 200; + +fn main() { + // Vello CPU supports applying luminance and alpha masks to your drawings. + + // First, we need to create our actual mask. There are multiple ways how + // you can get to it, in our case we are going to draw our own custom mask. + let mask = { + // In this case, we are drawing the mask ourselves. Note that the + // dimensions of the final mask need to match the dimensions of our + // original render context! + let mut mask_ctx = RenderContext::new(SIZE, SIZE); + let mut mask_resources = Resources::new(); + let mut pixmap = Pixmap::new(SIZE, SIZE); + + mask_ctx.set_paint(RED); + mask_ctx.fill_rect(&Rect::new(30.0, 30.0, 170.0, 170.0)); + mask_ctx.flush(); + mask_ctx.render_to_pixmap(&mut mask_resources, &mut pixmap); + + Mask::new_luminance(&pixmap) + }; + + // Create the main render context. + let mut ctx = RenderContext::new(SIZE, SIZE); + + // Similarly to clip paths (see the clipping example), there are two + // different ways of applying them: + // The first method is by creating a new isolated layer where the mask + // will be applied once the whole layer has been drawn and is composited + // into the backdrop. The second method is by setting the mask in the + // render context, in which case the mask will be applied to each shape + // directly before being drawn. Which method you should use once again + // depends on the imaging model you are reflecting. + + // Method 1: Non-isolated masking via `set_mask`. + { + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64)); + + // Once the mask is set, the mask will be applied to every path we + // are drawing individually before compositing it into the background. + ctx.set_mask(mask.clone()); + // We first apply the mask to the blue rectangle and then composite it. + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 130.0, 130.0)); + // Now, we yet again first apply the mask to the red rectangle only and + // then composite the result. + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(70.0, 70.0, 180.0, 180.0)); + // Use this method if you want to reset the mask currently in place. + ctx.reset_mask(); + + ctx.flush(); + save_pixmap(&ctx, "example_masking1"); + } + + ctx.reset(); + // Method 2: Isolated masking via `push_mask_layer`. + { + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64)); + + // Using this method, we first push a new isolated layer. Apart from that, + // nothing happens so far. + ctx.push_mask_layer(mask); + // Here, the blue rectangle will be drawn first. Then, the red one is drawn + // and subsequently composited on top of the blue one, + // without any special handling. + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 130.0, 130.0)); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(70.0, 70.0, 180.0, 180.0)); + // Now, the whole layer is taken, the mask is applied to all of it + // and then composited into the background. + ctx.pop_layer(); + + ctx.flush(); + save_pixmap(&ctx, "example_masking2"); + } + + // As can be seen, the visual result of the two methods can be different! +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} diff --git a/vendor/vello_cpu/examples/paints.rs b/vendor/vello_cpu/examples/paints.rs new file mode 100644 index 0000000000..3a764cd7e6 --- /dev/null +++ b/vendor/vello_cpu/examples/paints.rs @@ -0,0 +1,145 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Using gradients and patterns paints using Vello CPU. + +use std::sync::Arc; +use std::{io::Cursor, path::Path}; +use vello_cpu::{ + Image, ImageSource, Pixmap, RenderContext, Resources, + color::palette::css::{BLUE, CYAN, DEEP_PINK, MAGENTA, NAVY, ORANGE, PURPLE, WHITE, YELLOW}, + kurbo::{Affine, Point, Rect}, + peniko::{ + ColorStop, ColorStops, Extend, Gradient, ImageSampler, LinearGradientPosition, + RadialGradientPosition, SweepGradientPosition, + }, +}; + +fn main() { + // Apart from drawing shapes using a single color, you can also draw them + // using gradients and image patterns. + + let mut ctx = RenderContext::new(500, 500); + let base_rect = Rect::new(25.0, 25.0, 225.0, 225.0); + + // All you need to is to construct the given gradient/pattern and activate + // it by using the `set_paint` method. In this case, we are drawing a + // linear gradient. + ctx.set_paint(linear_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Since we are applying a transform to the render context, the radial + // gradient we are about to apply will also be affected to that. + ctx.set_transform(Affine::translate((250.0, 0.0))); + ctx.set_paint(radial_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Sweep gradient are also supported. + ctx.set_transform(Affine::translate((0.0, 250.0))); + ctx.set_paint(sweep_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Finally, we can use image patterns with different extend modes. + ctx.set_transform(Affine::translate((250.0, 250.0))); + ctx.set_paint(pattern()); + // If you want to apply an additional transform that just applies to the + // paint instead of the shape you are drawing, you can do that using the + // `set_paint_transform` method. + ctx.set_paint_transform(Affine::scale(15.0)); + ctx.fill_rect(&base_rect); + + ctx.flush(); + + save_pixmap(&ctx, "example_paint"); +} + +fn linear_gradient(rect: &Rect) -> Gradient { + Gradient { + kind: LinearGradientPosition { + start: Point::new(rect.x0, rect.y0), + end: Point::new(rect.x1, rect.y1), + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, DEEP_PINK)), + ColorStop::from((0.55, ORANGE)), + ColorStop::from((1.0, YELLOW)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn radial_gradient(rect: &Rect) -> Gradient { + let center = rect.center(); + #[allow(clippy::cast_possible_truncation, reason = "necessary for conversion")] + let radius = (rect.width().min(rect.height()) * 0.5) as f32; + Gradient { + kind: RadialGradientPosition { + start_center: center, + start_radius: radius * 0.2, + end_center: center, + end_radius: radius, + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, CYAN)), + ColorStop::from((0.6, BLUE)), + ColorStop::from((1.0, NAVY)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn sweep_gradient(rect: &Rect) -> Gradient { + let center = rect.center(); + Gradient { + kind: SweepGradientPosition { + center, + start_angle: 0.0, + end_angle: std::f32::consts::PI * 2.0, + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, MAGENTA)), + ColorStop::from((0.5, PURPLE)), + ColorStop::from((1.0, WHITE)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn pattern() -> Image { + // For patterns, you need to get access to a pixmap. You can either do this + // by rendering your own pixmap or converting for example a png image into + // one. + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../vello_sparse_tests/tests/assets/rgb_image_2x2.png"); + let file = std::fs::read(path).unwrap(); + let pixmap = Pixmap::from_png(Cursor::new(file)).unwrap(); + + Image { + // Note that only ImageSource::Pixmap is currently supported. Don't + // use ImageSource::OpaqueId. + image: ImageSource::Pixmap(Arc::new(pixmap)), + sampler: ImageSampler { + x_extend: Extend::Repeat, + y_extend: Extend::Repeat, + ..Default::default() + }, + } +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} + +fn color_stops(stops: [ColorStop; 3]) -> ColorStops { + ColorStops::from(stops.as_slice()) +} diff --git a/vendor/vello_cpu/src/dispatch/mod.rs b/vendor/vello_cpu/src/dispatch/mod.rs new file mode 100644 index 0000000000..f9830f3621 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/mod.rs @@ -0,0 +1,95 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#[cfg(feature = "multithreading")] +pub(crate) mod multi_threaded; +pub(crate) mod single_threaded; + +use crate::RenderMode; +use crate::kurbo::{Affine, BezPath, Rect, Stroke}; +use crate::peniko::{BlendMode, Fill}; +use core::fmt::Debug; +use vello_common::coarse::Wide; +use vello_common::encode::EncodedPaint; +use vello_common::filter_effects::Filter; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint}; + +pub(crate) trait Dispatcher: Debug + Send + Sync { + fn wide(&self) -> &Wide; + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + /// Fill a pixel-aligned rectangle with the current paint. + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ); + fn pop_clip_path(&mut self); + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + clip_transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ); + fn pop_layer(&mut self); + fn reset(&mut self); + fn flush(&mut self, encoded_paints: &[EncodedPaint]); + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ); + fn composite_at_offset( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + render_mode: RenderMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ); +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded.rs b/vendor/vello_cpu/src/dispatch/multi_threaded.rs new file mode 100644 index 0000000000..22fb125f90 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded.rs @@ -0,0 +1,900 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +use crate::dispatch::multi_threaded::cost::{COST_THRESHOLD, estimate_render_task_cost}; +use crate::dispatch::multi_threaded::worker::Worker; +use crate::fine::{Fine, FineKernel}; +use crate::kurbo::{Affine, BezPath, PathEl, Point, Rect, Stroke}; +use crate::peniko::{BlendMode, Fill}; +use crate::region::Regions; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt::{Debug, Formatter}; +use crossbeam_channel::TryRecvError; +use rayon::{ThreadPool, ThreadPoolBuilder}; +use std::cell::RefCell; +use std::ops::Range; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Barrier, Mutex}; +use thread_local::ThreadLocal; +use vello_common::clip::ClipContext; +use vello_common::coarse::{Cmd, MODE_CPU, Wide}; +use vello_common::encode::EncodedPaint; +use vello_common::fearless_simd::{Level, Simd, dispatch}; +use vello_common::filter_effects::Filter; +use vello_common::geometry::RectU16; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint}; +use vello_common::render_graph::RenderGraph; +use vello_common::strip::Strip; +use vello_common::strip_generator::StripGenerator; + +mod cost; +mod worker; + +type RenderTaskSender = crossbeam_channel::Sender; +type CoarseTaskSender = ordered_channel::Sender; +type CoarseTaskReceiver = ordered_channel::Receiver; + +/// A dispatcher for multi-threaded rendering. +/// +/// A small note for future contributors: Unfortunately, the logic of this dispatcher as well as +/// the lifecycle of the different fields of the dispatcher can be a bit hard to grasp. +/// The reason for this is that since we have to do a lot of communication across the thread boundary, +/// we have to work with lots of `Option` and `core::mem::take` operations, to ensure that we are +/// not needlessly cloning objects. +/// +/// The below comments will hopefully help with understanding the overall structure and lifecycles +/// a bit better. +pub(crate) struct MultiThreadedDispatcher { + /// The wide tile container. + wide: Wide, + clip_context: ClipContext, + /// The thread pool that is used for dispatching tasks. + thread_pool: ThreadPool, + allocation_group: AllocationGroup, + /// The cost of the current batch. + batch_cost: f32, + /// The sender used to dispatch new rendering tasks from the main thread. + /// + /// This field will be set once we call the `init` method. + /// This field will be set back to `None` when running `flush` to drop the value and thus + /// indicate to receivers that no more rendering tasks will be dispatched from that point onward. + task_sender: Option, + /// Contains one worker object for each thread. + /// + /// The workers will be initialized once when building the multi-threaded dispatcher via + /// `MultiThreadedDispatcher::new`. + workers: Arc>>, + /// The receiver for coarse command tasks, used to do coarse rasterization on the main thread. + /// + /// Similarly to `task_sender`, this value is set to `None` initially, and will only be set once + /// we actually call the `init` method (either when creating the dispatcher for the first time, or + /// when resetting it). + coarse_task_receiver: Option, + /// The storage for alpha values. + /// + /// Similarly to the single-threaded dispatcher, we want to be able to reuse the allocation holding + /// the alpha values across multiple runs of `reset`. However, we have the problem that during path + /// rendering, each thread needs to have its own allocation. We also need to be able to move + /// the allocation back and forth between the threads (during path rendering) and the main thread + /// (during fine rasterization). Because of this, we wrap it in this `MaybePresent` struct. + /// + /// During initialization, each thread will "take" the vector allocation out of its slot + /// (the vector has a length of `num_threads`, so each thread has a slot belonging to itself) + /// and will put it back to its slot after flushing. Then, during fine rasterization, we + /// take all slots out of the `MaybePresent` object so that we can easily access each buffer + /// when running the commands without having to go through the mutex. After fine rasterization, + /// the slots are put back into the `MaybePresent` object. + /// + alpha_storage: MaybePresent>>, + /// The task index that will be assigned to the next rendering task. + /// + /// Since we are rendering the paths on different threads, we need to make sure that they + /// come back in the right order. The `task_idx` is used to keep track of that order. + task_idx: u32, + /// The number of threads active in the thread pool. + num_threads: u16, + /// The strip generator for the main thread, used for clip path rasterization. + strip_generator: StripGenerator, + level: Level, + flushed: bool, + // So that we can reuse memory allocations across different runs. + allocations: Allocations, + /// Render graph (unused in multi-threaded, only needed for API compatibility with Wide). + render_graph: RenderGraph, +} + +impl MultiThreadedDispatcher { + pub(crate) fn new(width: u16, height: u16, num_threads: u16, level: Level) -> Self { + let wide = Wide::::new(width, height); + let thread_pool = ThreadPoolBuilder::new() + .num_threads(num_threads as usize) + .build() + .unwrap(); + let alpha_storage = MaybePresent::new(vec![vec![]; usize::from(num_threads)]); + let workers = Arc::new(ThreadLocal::new()); + + { + let thread_ids = Arc::new(AtomicU8::new(0)); + let workers = workers.clone(); + + // Create all workers once in `new`, so that later on we can just call`.get().unwrap()`. + thread_pool.spawn_broadcast(move |_| { + let thread_id = thread_ids.fetch_add(1, Ordering::SeqCst); + let worker = Worker::new(width, height, thread_id, level); + + let _ = workers.get_or(|| RefCell::new(worker)); + }); + } + + let task_idx = 0; + let batch_cost = 0.0; + let flushed = false; + + let mut dispatcher = Self { + wide, + thread_pool, + allocations: Allocations::default(), + allocation_group: AllocationGroup::default(), + batch_cost, + task_idx, + flushed, + workers, + clip_context: ClipContext::new(), + task_sender: None, + coarse_task_receiver: None, + strip_generator: StripGenerator::new(width, height, level), + level, + alpha_storage, + num_threads, + render_graph: RenderGraph::new(), + }; + + dispatcher.init(); + + dispatcher + } + + #[cfg(feature = "f32_pipeline")] + fn rasterize_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + #[cfg(feature = "u8_pipeline")] + fn rasterize_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + fn init(&mut self) { + let (render_task_sender, render_task_receiver) = crossbeam_channel::unbounded(); + let (coarse_task_sender, coarse_task_receiver) = ordered_channel::unbounded(); + let workers = self.workers.clone(); + let alpha_storage = self.alpha_storage.clone(); + + self.task_sender = Some(render_task_sender); + self.coarse_task_receiver = Some(coarse_task_receiver); + + // Spawn the loop for the worker threads. + self.thread_pool.spawn_broadcast(move |_| { + let render_task_receiver = render_task_receiver.clone(); + let mut coarse_task_sender = coarse_task_sender.clone(); + let worker = workers.get().unwrap(); + let mut worker = worker.borrow_mut(); + let thread_id = worker.thread_id(); + + // Take out the allocation for alphas and store it in the worker. + alpha_storage + .with_inner(|alphas| worker.init(std::mem::take(&mut alphas[thread_id as usize]))); + + while let Ok(task) = render_task_receiver.recv() { + worker.run_render_task(task, &mut coarse_task_sender); + } + + // If we reach this point, it means the `task_sender` has been dropped by the main thread + // and no more tasks are available (since we flushed). + // So we are done, and just need to place the alphas of the worker thread back into the + // vector. + + alpha_storage.with_inner(|alphas| { + alphas[thread_id as usize] = worker.finalize(); + }); + + // Then, we drop the `coarse_task_sender`. Once all worker threads have + // dropped their `coarse_task_sender`, the main thread knows that all workers are done + // and all alphas have been placed, so it's safe to proceed. + drop(coarse_task_sender); + }); + } + + fn register_task(&mut self, task: RenderTaskType) { + self.flushed = false; + if self.task_sender.is_none() { + self.init(); + } + + let cost = estimate_render_task_cost(&task, &self.allocation_group.path); + self.allocation_group.render_tasks.push(task); + self.batch_cost += cost; + + if self.batch_cost > COST_THRESHOLD { + self.flush_tasks(); + } + } + + fn flush_tasks(&mut self) { + self.send_pending_tasks(); + + self.batch_cost = 0.0; + } + + fn bump_task_idx(&mut self) -> u32 { + let idx = self.task_idx; + self.task_idx += 1; + idx + } + + fn send_pending_tasks(&mut self) { + let task_idx = self.bump_task_idx(); + let allocation_group = + std::mem::replace(&mut self.allocation_group, self.allocations.get()); + let task_sender = self.task_sender.as_mut().unwrap(); + let clip_path = self.clip_context.get().map(|c| OwnedClip { + strips: c.strips.into(), + alphas: c.alphas.into(), + bbox: c.bbox, + }); + let task = RenderTask { + idx: task_idx, + clip_path, + allocation_group, + }; + task_sender.send(task).unwrap(); + // TODO: Pass encoded_paints here to enable overdraw elimination for opaque indexed + // paints. Currently we pass an empty slice, so indexed paints render correctly but miss + // the FillHint::OpaqueImage optimization. The challenge is that encoded_paints is a + // borrowed reference that may not be valid by the time coarse processing runs asynchronously. + self.run_coarse(true, &[]); + } + + // Currently, we do coarse rasterization in two phases: + // + // The first phase is when we are still processing new draw commands from the client. After each + // command, we check whether there are already any generated strips, and if so we do coarse + // rasterization for them on the main thread. In this case, we want to abort in case there are + // no more path strips available to process. + // + // The second phase is when we are flushing, in which case even if the queue is empty, we only + // want to abort once all workers have closed the channel (and thus there won't be any more + // new strips that will be generated. + // + // This is why we have the `abort_empty`flag. + fn run_coarse(&mut self, abort_empty: bool, encoded_paints: &[EncodedPaint]) { + let result_receiver = self.coarse_task_receiver.as_mut().unwrap(); + + loop { + match result_receiver.try_recv() { + Ok(mut task) => { + let num_tasks = task.allocation_group.coarse_tasks.len(); + for cmd in task.allocation_group.coarse_tasks.drain(0..num_tasks) { + match cmd { + CoarseTaskType::RenderPath { + strips: strip_range, + paint, + blend_mode, + thread_id, + mask, + } => self.wide.generate( + &task.allocation_group.strips + [strip_range.start as usize..strip_range.end as usize], + paint.clone(), + blend_mode, + thread_id, + mask, + encoded_paints, + ), + CoarseTaskType::PushLayer { + thread_id, + clip_path, + blend_mode, + mask, + opacity, + } => { + let clip_path = clip_path.map(|strip_range| { + &task.allocation_group.strips + [strip_range.start as usize..strip_range.end as usize] + }); + + // layer_id 0 and filter None since filters aren't supported + self.wide.push_layer( + 0, + clip_path, + blend_mode, + mask, + opacity, + None, + // Transform can be IDENTITY because filters aren't supported in multi-threaded mode + Affine::IDENTITY, + &mut self.render_graph, + thread_id, + ); + } + CoarseTaskType::PopLayer => self.wide.pop_layer(&mut self.render_graph), + } + } + + // Put the allocation group back so it can be reused in future iterations! + self.allocations.put(task.allocation_group); + } + Err(e) => match e { + TryRecvError::Empty => { + if abort_empty { + return; + } + } + TryRecvError::Disconnected => return, + }, + } + } + } + + fn rasterize_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut buffer = Regions::new(width, height, buffer); + let fines = ThreadLocal::new(); + let wide = &self.wide; + let alpha_slots = self.alpha_storage.take(); + + self.thread_pool.install(|| { + buffer.update_regions_par(|region| { + let x = region.x; + let y = region.y; + + let mut fine = fines + .get_or(|| RefCell::new(Fine::::new(simd))) + .borrow_mut(); + + let wtile = wide.get(x, y); + fine.set_coords(x, y); + + fine.clear(wtile.bg); + for cmd in &wtile.cmds { + let thread_idx = match cmd { + Cmd::AlphaFill(a) => Some(wide.attrs.fill[a.attrs_idx as usize].thread_idx), + Cmd::ClipStrip(a) => Some(wide.attrs.clip[a.attrs_idx as usize].thread_idx), + _ => None, + }; + + let alphas = thread_idx + .map(|i| alpha_slots[i as usize].as_slice()) + .unwrap_or(&[]); + fine.run_cmd(cmd, alphas, encoded_paints, image_resolver, &wide.attrs); + } + + fine.pack(region); + }); + }); + + // Don't forget to put back the alpha buffers, so that they can be re-used in + // the next path rendering iteration! + self.alpha_storage.init(alpha_slots); + } +} + +impl Dispatcher for MultiThreadedDispatcher { + fn wide(&self) -> &Wide { + &self.wide + } + + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(path); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::FillPath { + path_range: start..end, + transform, + paint, + fill_rule, + blend_mode, + aliasing_threshold, + mask, + }); + } + + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(path); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::StrokePath { + path_range: start..end, + transform, + paint, + stroke: stroke.clone(), + blend_mode, + aliasing_threshold, + mask, + }); + } + + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + // For multi-threaded, fall back to path-based rendering. + // TODO: Implement optimized rect strip generation in worker threads. + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend([ + PathEl::MoveTo(Point::new(rect.x0, rect.y0)), + PathEl::LineTo(Point::new(rect.x1, rect.y0)), + PathEl::LineTo(Point::new(rect.x1, rect.y1)), + PathEl::LineTo(Point::new(rect.x0, rect.y1)), + PathEl::ClosePath, + ]); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::FillPath { + path_range: start..end, + transform: Affine::IDENTITY, + paint, + fill_rule: Fill::NonZero, + blend_mode, + aliasing_threshold: None, + mask, + }); + } + + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + clip_transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ) { + // TODO: Implement filter support in multi-threaded dispatcher. + // The single-threaded dispatcher has full support via LayerManager and render graph execution, + // but multi-threaded needs additional infrastructure for cross-thread layer coordination. + if filter.is_some() { + unimplemented!("Filter effects are not yet supported in multi-threaded rendering"); + } + + let mapped_clip = clip_path.map(|c| { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(c); + let end = self.allocation_group.path.len() as u32; + (start..end, clip_transform) + }); + + self.register_task(RenderTaskType::PushLayer { + clip_path: mapped_clip, + blend_mode, + opacity, + mask, + fill_rule, + aliasing_threshold, + }); + } + + fn pop_layer(&mut self) { + self.register_task(RenderTaskType::PopLayer); + } + + fn reset(&mut self) { + self.wide.reset(); + self.clip_context.reset(); + self.allocation_group.clear(); + self.batch_cost = 0.0; + self.task_idx = 0; + self.flushed = false; + self.task_sender = None; + self.coarse_task_receiver = None; + self.strip_generator.reset(); + self.alpha_storage.with_inner(|alphas| { + for alpha in alphas { + alpha.clear(); + } + }); + + let workers = self.workers.clone(); + // + 1 since we also wait on the main thread. + let barrier = Arc::new(Barrier::new(usize::from(self.num_threads) + 1)); + let t_barrier = barrier.clone(); + + self.thread_pool.spawn_broadcast(move |_| { + let worker = workers.get().unwrap(); + let mut borrowed = worker.borrow_mut(); + borrowed.reset(); + t_barrier.wait(); + }); + + barrier.wait(); + + self.init(); + } + + fn flush(&mut self, encoded_paints: &[EncodedPaint]) { + if self.flushed { + return; + } + + self.flush_tasks(); + let sender = core::mem::take(&mut self.task_sender); + // Note that dropping the sender will signal to the workers that no more new paths + // can arrive. + drop(sender); + self.run_coarse(false, encoded_paints); + + self.flushed = true; + } + + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + assert!(self.flushed, "attempted to rasterize before flushing"); + + // Only u8 pipeline enabled + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + // Only f32 pipeline enabled + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + + // Both pipelines enabled + #[cfg(all(feature = "f32_pipeline", feature = "u8_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + RenderMode::OptimizeQuality => { + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + } + } + + fn composite_at_offset( + &self, + _buffer: &mut [u8], + _width: u16, + _height: u16, + _dst_x: u16, + _dst_y: u16, + _dst_buffer_width: u16, + _dst_buffer_height: u16, + _render_mode: RenderMode, + _encoded_paints: &[EncodedPaint], + _image_resolver: &dyn ImageResolver, + ) { + // TODO: Implement composite_at_offset for multi-threaded dispatcher. + unimplemented!("composite_at_offset is not implemented for multi-threaded dispatcher"); + } + + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.flush_tasks(); + self.clip_context.push_clip( + path, + &mut self.strip_generator, + fill_rule, + transform, + aliasing_threshold, + ); + } + + fn pop_clip_path(&mut self) { + self.flush_tasks(); + self.clip_context.pop_clip(); + } +} + +impl Debug for MultiThreadedDispatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + f.write_str("MultiThreadedDispatcher { .. }") + } +} + +#[derive(Debug)] +pub(crate) struct OwnedClip { + strips: Box<[Strip]>, + alphas: Box<[u8]>, + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + bbox: RectU16, +} + +/// A structure that allows storing and fetching existing allocations. +struct AllocationManager { + entries: Vec>, +} + +impl AllocationManager { + /// Get a new vector allocation. + /// + /// The vector is guaranteed to have been cleared before. + fn get(&mut self) -> Vec { + self.entries.pop().unwrap_or_default() + } + + /// Insert a new allocation in the store. + fn put(&mut self, mut allocation: Vec) { + allocation.clear(); + self.entries.push(allocation); + } +} + +impl Default for AllocationManager { + fn default() -> Self { + Self { entries: vec![] } + } +} + +/// A structure to keep track of allocations that will be done while rendering with +/// multi-threading. +#[derive(Default)] +struct Allocations { + /// The render tasks of a batch. They will be filled by the main thread as new fill/stroke commands + /// come in and consumed by worker threads as they process them. + render_tasks: AllocationManager, + /// The path store of a batch. It will be filled by the main thread as new commands come in + /// and be used by the worker thread to generate the strips of a path. + paths: AllocationManager, + /// Stores allocations that are used by the worker thread to produce strips. They will be + /// sent back to the main thread which then uses them for coarse rasterization. + strips: AllocationManager, + /// The coarse tasks produced by a worker thread, which will be processed by the main thread. + coarse_tasks: AllocationManager, +} + +impl Allocations { + /// Return a new allocation group. + /// + /// The group is guaranteed to have been cleared. + fn get(&mut self) -> AllocationGroup { + let render_tasks = self.render_tasks.get(); + let path = self.paths.get(); + let strips = self.strips.get(); + let coarse_tasks = self.coarse_tasks.get(); + + AllocationGroup { + path, + render_tasks, + coarse_tasks, + strips, + } + } + + fn put(&mut self, allocation: AllocationGroup) { + self.render_tasks.put(allocation.render_tasks); + self.paths.put(allocation.path); + self.strips.put(allocation.strips); + self.coarse_tasks.put(allocation.coarse_tasks); + } +} + +#[derive(Default, Debug)] +pub(crate) struct AllocationGroup { + pub(crate) path: Vec, + pub(crate) render_tasks: Vec, + pub(crate) strips: Vec, + pub(crate) coarse_tasks: Vec, +} + +impl AllocationGroup { + fn clear(&mut self) { + self.path.clear(); + self.render_tasks.clear(); + self.strips.clear(); + self.coarse_tasks.clear(); + } +} + +#[derive(Debug)] +pub(crate) struct RenderTask { + pub(crate) idx: u32, + pub(crate) clip_path: Option, + pub(crate) allocation_group: AllocationGroup, +} + +#[derive(Debug, Clone)] +pub(crate) enum RenderTaskType { + FillPath { + path_range: Range, + transform: Affine, + paint: Paint, + fill_rule: Fill, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + }, + StrokePath { + path_range: Range, + transform: Affine, + paint: Paint, + stroke: Stroke, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + }, + PushLayer { + clip_path: Option<(Range, Affine)>, + blend_mode: BlendMode, + opacity: f32, + mask: Option, + fill_rule: Fill, + aliasing_threshold: Option, + }, + PopLayer, +} + +pub(crate) struct CoarseTask { + allocation_group: AllocationGroup, +} + +#[derive(Debug)] +pub(crate) enum CoarseTaskType { + RenderPath { + thread_id: u8, + strips: Range, + blend_mode: BlendMode, + paint: Paint, + mask: Option, + }, + PushLayer { + thread_id: u8, + clip_path: Option>, + blend_mode: BlendMode, + mask: Option, + opacity: f32, + }, + PopLayer, +} + +/// An object that might hold a certain value (behind a mutex), and panics if we attempt +/// to access it when it's not initialized. +#[derive(Clone)] +pub(crate) struct MaybePresent { + present: Arc, + value: Arc>, +} + +impl MaybePresent { + pub(crate) fn new(val: T) -> Self { + Self { + present: Arc::new(AtomicBool::new(true)), + value: Arc::new(Mutex::new(val)), + } + } + + pub(crate) fn init(&self, value: T) { + let mut locked = self.value.lock().unwrap(); + *locked = value; + self.present.store(true, Ordering::SeqCst); + } + + pub(crate) fn with_inner(&self, mut func: impl FnMut(&mut T)) { + assert!( + self.present.load(Ordering::SeqCst), + "Tried to access `MaybePresent` before initialization." + ); + + let mut lock = self.value.lock().unwrap(); + func(&mut lock); + } + + pub(crate) fn take(&self) -> T { + assert!( + self.present.load(Ordering::SeqCst), + "Tried to access `MaybePresent` before initialization." + ); + + let mut locked = self.value.lock().unwrap(); + self.present.store(false, Ordering::SeqCst); + std::mem::take(&mut *locked) + } +} + +#[cfg(test)] +mod tests { + use crate::Level; + use crate::color::palette::css::BLUE; + use crate::dispatch::Dispatcher; + use crate::dispatch::multi_threaded::MultiThreadedDispatcher; + use crate::kurbo::{Affine, Rect, Shape}; + use crate::peniko::{BlendMode, Fill}; + use vello_common::paint::{Paint, PremulColor}; + + /// Ensure we don't cause a memory leak. + #[test] + fn allocations() { + let mut dispatcher = MultiThreadedDispatcher::new(100, 100, 4, Level::new()); + for _ in 0..20 { + dispatcher.fill_path( + &Rect::new(0.0, 0.0, 50.0, 50.0).to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + Paint::Solid(PremulColor::from_alpha_color(BLUE)), + BlendMode::default(), + None, + None, + &[], + ); + dispatcher.flush(&[]); + } + + assert_eq!(dispatcher.allocations.paths.entries.len(), 1); + assert_eq!(dispatcher.allocations.strips.entries.len(), 1); + assert_eq!(dispatcher.allocations.render_tasks.entries.len(), 1); + assert_eq!(dispatcher.allocations.coarse_tasks.entries.len(), 1); + } +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs b/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs new file mode 100644 index 0000000000..2c84a75523 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs @@ -0,0 +1,130 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// There is not much science behind this constant. It was instead determined by doing profiling +/// and finding a constant that seems to strike a reasonable trade-off between not causing too +/// big batch sizes +pub(crate) const COST_THRESHOLD: f32 = 250.0; + +use crate::dispatch::multi_threaded::RenderTaskType; +use crate::kurbo::{Affine, PathEl, PathSeg, Point, segments}; + +/// Try to estimate the cost of the render task. +pub(crate) fn estimate_render_task_cost(task: &RenderTaskType, paths: &[PathEl]) -> f32 { + const LAYER_COST: f32 = 10.0; + + match task { + RenderTaskType::FillPath { + path_range, + transform, + .. + } => { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, false) + } + RenderTaskType::StrokePath { + path_range, + transform, + .. + } => { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, true) + } + RenderTaskType::PushLayer { clip_path, .. } => { + LAYER_COST + + clip_path + .as_ref() + .map(|(path_range, transform)| { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, false) + }) + .unwrap_or(0.0) + } + RenderTaskType::PopLayer => LAYER_COST, + } +} + +/// Try to estimate an (admittedly somewhat abstract) "path cost". +/// +/// The main point here is that when sending paths to a thread to convert them into sparse strip +/// representation, we might want to batch them. This is especially the case for small, line-only +/// geometries, where handling each path separately would lead to a huge overhead. +/// +/// Because of this, before rendering a path, we try to estimate a very rough cost based on +/// the following attributes that (usually) have an impact on rendering times: +/// +/// - Number of line segments (more line segments -> more work during strip rendering). +/// - Number of curve segments (same as line segments, plus we need to flatten them first). +/// - Path length (if the path is longer, the covered area is _likely_ to also be larger). However, +/// the path length usually grows much faster than the render time, so we only apply a very small +/// fractional value. +/// - Strokes (if we are stroking a path, there is even more overhead for stroke expansion before doing +/// flattening and strip rendering). +pub(crate) fn estimate_path_cost( + path: impl IntoIterator, + transform: Affine, + is_stroke: bool, +) -> f32 { + let cost_data = PathCostData::new(path, transform); + + // Once again, those constants were not determined "scientifically" in any way, but + // are instead based on intuition as well as a number of experiments. + const CURVE_MULTIPLIER: f32 = 2.5; + const STROKE_MULTIPLIER: f32 = 1.5; + + let mut cost = cost_data.num_line_segments as f32; + cost += cost_data.num_curve_segments as f32 * CURVE_MULTIPLIER; + cost += cost * (cost_data.path_length as f32 / 1024.0); + + cost *= if is_stroke { STROKE_MULTIPLIER } else { 1.0 }; + cost +} + +struct PathCostData { + num_line_segments: u64, + num_curve_segments: u64, + path_length: f64, +} + +impl PathCostData { + fn new(path: impl IntoIterator, transform: Affine) -> Self { + let mut num_line_segments = 0; + let mut num_curve_segments = 0; + let mut path_length = 0.0; + + let mut register_path_length = |mut p0: Point, mut p1: Point| { + p0 = transform * p0; + p1 = transform * p1; + // We don't sqrt here because it's too expensive, we just want a rough estimate. + let dx = (p1.x - p0.x).abs(); + let dy = (p1.y - p0.y).abs(); + path_length += dx + dy; + }; + + for seg in path.into_iter() { + match seg { + PathSeg::Line(l) => { + num_line_segments += 1; + + register_path_length(l.p0, l.p1); + } + PathSeg::Quad(q) => { + num_curve_segments += 1; + + register_path_length(q.p0, q.p2); + } + PathSeg::Cubic(c) => { + num_curve_segments += 1; + + register_path_length(c.p0, c.p3); + } + } + } + + Self { + num_line_segments, + num_curve_segments, + path_length, + } + } +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs b/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs new file mode 100644 index 0000000000..2a4737ca63 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs @@ -0,0 +1,204 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::Level; +use crate::dispatch::multi_threaded::{ + CoarseTask, CoarseTaskSender, CoarseTaskType, RenderTask, RenderTaskType, +}; +use std::vec::Vec; +use vello_common::clip::PathDataRef; +use vello_common::strip_generator::{GenerationMode, StripGenerator, StripStorage}; + +#[derive(Debug)] +pub(crate) struct Worker { + strip_generator: StripGenerator, + strip_storage: StripStorage, + thread_id: u8, +} + +impl Worker { + pub(crate) fn new(width: u16, height: u16, thread_id: u8, level: Level) -> Self { + let strip_generator = StripGenerator::new(width, height, level); + let strip_storage = StripStorage::default(); + + Self { + strip_generator, + strip_storage, + thread_id, + } + } + + pub(crate) fn init(&mut self, alphas: Vec) { + self.strip_storage.alphas = alphas; + } + + pub(crate) fn thread_id(&self) -> u8 { + self.thread_id + } + + pub(crate) fn reset(&mut self) { + self.strip_generator.reset(); + } + + pub(crate) fn run_render_task( + &mut self, + mut render_task: RenderTask, + result_sender: &mut CoarseTaskSender, + ) { + let num_tasks = render_task.allocation_group.render_tasks.len(); + self.strip_storage.strips.clear(); + self.strip_storage + .set_generation_mode(GenerationMode::Append); + let task_idx = render_task.idx; + let path_clip = render_task.clip_path.as_ref().map(|c| PathDataRef { + strips: c.strips.as_ref(), + alphas: c.alphas.as_ref(), + bbox: c.bbox, + }); + + for task in render_task + .allocation_group + .render_tasks + .drain(0..num_tasks) + { + match task { + RenderTaskType::FillPath { + path_range, + transform, + paint, + fill_rule, + blend_mode, + aliasing_threshold, + mask, + } => { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_filled_path( + path.iter().copied(), + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + let end = self.strip_storage.strips.len() as u32; + + let coarse_command = CoarseTaskType::RenderPath { + thread_id: self.thread_id, + strips: start..end, + blend_mode, + paint, + mask, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::StrokePath { + path_range, + transform, + paint, + blend_mode, + stroke, + aliasing_threshold, + mask, + } => { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_stroked_path( + path.iter().copied(), + &stroke, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + let end = self.strip_storage.strips.len() as u32; + + let coarse_command = CoarseTaskType::RenderPath { + thread_id: self.thread_id, + strips: start..end, + blend_mode, + paint, + mask, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::PushLayer { + clip_path, + blend_mode, + opacity, + mask, + fill_rule, + aliasing_threshold, + } => { + let clip = if let Some((path_range, transform)) = clip_path { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_filled_path( + path.iter().copied(), + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + + let end = self.strip_storage.strips.len() as u32; + + Some(start..end) + } else { + None + }; + + let coarse_command = CoarseTaskType::PushLayer { + thread_id: self.thread_id, + clip_path: clip, + blend_mode, + mask, + opacity, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::PopLayer => { + render_task + .allocation_group + .coarse_tasks + .push(CoarseTaskType::PopLayer); + } + } + } + + let taken_strips = std::mem::replace( + &mut self.strip_storage.strips, + render_task.allocation_group.strips, + ); + render_task.allocation_group.strips = taken_strips; + + let task = CoarseTask { + allocation_group: render_task.allocation_group, + }; + + result_sender.send(task_idx as usize, task).unwrap(); + } + + pub(crate) fn finalize(&mut self) -> Vec { + std::mem::take(&mut self.strip_storage.alphas) + } +} diff --git a/vendor/vello_cpu/src/dispatch/single_threaded.rs b/vendor/vello_cpu/src/dispatch/single_threaded.rs new file mode 100644 index 0000000000..2a08d4b1b2 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/single_threaded.rs @@ -0,0 +1,922 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +use crate::fine::{Fine, FineKernel}; +use crate::kurbo::{Affine, BezPath, Rect, Stroke}; +use crate::layer_manager::LayerManager; +use crate::peniko::{BlendMode, Fill}; +use crate::region::Regions; +use vello_common::clip::ClipContext; +use vello_common::coarse::{Cmd, LayerKind, MODE_CPU, Wide, WideTilesBbox}; +use vello_common::color::palette::css::TRANSPARENT; +use vello_common::encode::EncodedPaint; +use vello_common::fearless_simd::{Level, Simd}; +use vello_common::filter_effects::Filter; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint, PremulColor}; +use vello_common::pixmap::Pixmap; +use vello_common::render_graph::{RenderGraph, RenderNodeKind}; +use vello_common::strip_generator::{StripGenerator, StripStorage}; + +/// Single-threaded implementation of the rendering dispatcher. +/// +/// This dispatcher handles the entire rendering pipeline on a single thread, +/// including path rasterization, layer composition, and filter effects. +/// It maintains the coarse tile grid (`Wide`), strip generation for paths, +/// and the render graph for managing layer dependencies and filter effects. +#[derive(Debug)] +pub(crate) struct SingleThreadedDispatcher { + /// Coarse tile grid containing rendering commands for each wide tile. + wide: Wide, + /// Clip context for managing non-isolated clipping. + clip_context: ClipContext, + /// Generator for converting paths into coverage strips. + strip_generator: StripGenerator, + /// Storage for alpha coverage data from strip generation. + strip_storage: StripStorage, + /// SIMD level for fearless SIMD dispatch. + level: Level, + /// Counter for generating unique layer IDs. + layer_id_next: u32, + /// Dependency graph tracking layer relationships and filter effects. + render_graph: RenderGraph, +} + +impl SingleThreadedDispatcher { + /// Creates a new single-threaded dispatcher for the given dimensions. + /// + /// # Arguments + /// * `width` - Width of the rendering surface in pixels. + /// * `height` - Height of the rendering surface in pixels. + /// * `level` - SIMD level to use for rasterization. + /// + /// # Notes + /// The root layer (`layer_id` 0) is created immediately and must be node 0 + /// in the render graph for proper rendering order. + pub(crate) fn new(width: u16, height: u16, level: Level) -> Self { + let wide = Wide::::new(width, height); + let strip_generator = StripGenerator::new(width, height, level); + let clip_context = ClipContext::new(); + let strip_storage = StripStorage::default(); + let mut render_graph = RenderGraph::new(); + + // Create root node (layer_id 0) as the first node (will be node 0). + // This ensures the root layer is always rendered last in the execution order. + let wtile_bbox = WideTilesBbox::new(0, 0, wide.width_tiles(), wide.height_tiles()); + let root_node = render_graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox, + }); + assert_eq!(root_node, 0, "Root node must be node 0"); + + Self { + wide, + clip_context, + strip_generator, + strip_storage, + level, + layer_id_next: 0, + render_graph, + } + } + + /// Rasterizes the scene using f32 precision (high quality). + /// + /// This dispatches to the appropriate SIMD implementation based on the + /// configured level, using f32 for intermediate calculations. + #[cfg(feature = "f32_pipeline")] + fn rasterize_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + /// Rasterizes the scene using u8 precision (fast). + /// + /// This dispatches to the appropriate SIMD implementation based on the + /// configured level, using u8 for intermediate calculations to maximize speed. + #[cfg(feature = "u8_pipeline")] + fn rasterize_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + /// Core rasterization dispatcher that chooses between simple and filter-aware paths. + /// + /// # Type Parameters + /// * `S` - SIMD implementation to use. + /// * `F` - Fine rasterization kernel (determines precision). + /// + /// If the scene contains filter effects, uses the filter-aware path which maintains + /// intermediate layer buffers. Otherwise, uses the simpler direct rasterization path. + fn rasterize_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut layer_manager = LayerManager::new(); + + if self.has_filters() { + // Use filter-aware path that maintains layer buffers for filter effects. + self.rasterize_with_filters::( + simd, + buffer, + width, + height, + encoded_paints, + image_resolver, + &mut layer_manager, + ); + } else { + // Use simple direct rasterization for scenes without filters. + self.rasterize_simple::( + simd, + buffer, + width, + height, + encoded_paints, + image_resolver, + ); + } + } + + /// Rasterizes a scene with filter effects using dependency-ordered execution. + /// + /// This processes the render graph in topological order, ensuring that filtered + /// layers are rendered into intermediate buffers before being composed. Each + /// filter layer is rendered to its own pixmap, the filter is applied, and then + /// the result is stored in the layer manager for use by dependent layers. + /// + /// # Render Graph Execution + /// - `FilterLayer` nodes: Render to intermediate buffer, apply filter, store result. + /// - `RootLayer` node: Final composition to output buffer. + fn rasterize_with_filters>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + layer_manager: &mut LayerManager, + ) { + let mut fine = Fine::::new(simd); + + // Process nodes in dependency order (filtered layers before their consumers). + for node_id in self.render_graph.execution_order() { + let node = &self.render_graph.nodes[node_id]; + + match &node.kind { + RenderNodeKind::FilterLayer { + layer_id, + filter, + wtile_bbox, + transform, + } => { + // Allocate intermediate buffer for this filtered layer. + let bbox_width = wtile_bbox.width_px(); + let bbox_height = wtile_bbox.height_px(); + let mut pixmap = Pixmap::new(bbox_width, bbox_height); + // TODO: Re-use this allocation by adding a .configure() or similar method + // to avoid allocating the internal Vec on every filtered layer. + let mut regions = + Regions::new(bbox_width, bbox_height, pixmap.data_as_u8_slice_mut()); + + // Render each tile in the layer's bounding box. + regions.update_regions(|region| { + // Convert region-local coords to global wtile coords. + let x = wtile_bbox.x0() + region.x; + let y = wtile_bbox.y0() + region.y; + + self.process_layer_tile( + &mut fine, + x, + y, + *layer_id, + PremulColor::from_alpha_color(TRANSPARENT), + layer_manager, + encoded_paints, + image_resolver, + ); + + debug_assert_eq!( + fine.blend_buf.len(), + 1, + "blend buffer should contain exactly one layer after tile processing" + ); + + fine.pack(region); + }); + + // Apply the filter effect to the completed layer. + fine.filter_layer(&mut pixmap, filter, layer_manager, *transform); + + // Save the filtered pixmap to disk for debugging. + // #[cfg(all(debug_assertions, feature = "std", feature = "png"))] + // save_filtered_layer_debug(&pixmap, *layer_id); + + // Store the filtered result for use by dependent layers. + layer_manager.register_layer(*layer_id, *wtile_bbox, pixmap); + } + RenderNodeKind::RootLayer { + layer_id, + wtile_bbox: _, + } => { + // Final composition directly to output buffer. + let mut regions = Regions::new(width, height, buffer); + regions.update_regions(|region| { + // Use the background color from the wide tile. + let bg = self.wide.get(region.x, region.y).bg; + self.process_layer_tile( + &mut fine, + region.x, + region.y, + *layer_id, + bg, + layer_manager, + encoded_paints, + image_resolver, + ); + + debug_assert_eq!( + fine.blend_buf.len(), + 1, + "blend buffer should contain exactly one layer after tile processing" + ); + + fine.pack(region); + }); + } + } + } + } + + /// Processes all rendering commands for a single layer within a specific tile. + /// + /// This handles the complex logic of composing filtered layers by: + /// 1. Running normal rendering commands in sequence. + /// 2. When encountering a filtered layer reference, compositing its pre-rendered + /// content from the layer manager. + /// 3. Skipping the filtered layer's internal commands (already rendered separately). + /// + /// # Arguments + /// * `fine` - The fine rasterizer instance. + /// * `x`, `y` - Wide tile coordinates. + /// * `layer_id` - The layer being processed. + /// * `clear_color` - Initial color for the tile. + /// * `layer_manager` - Storage for filtered layer buffers. + /// * `encoded_paints` - Paint definitions for the scene. + /// * `image_resolver` - Resolver for looking up opaque image IDs. + fn process_layer_tile>( + &self, + fine: &mut Fine, + x: u16, + y: u16, + layer_id: u32, + clear_color: PremulColor, + layer_manager: &mut LayerManager, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let wtile = &self.wide.get(x, y); + fine.set_coords(x, y); + fine.clear(clear_color); + + // Process all commands in this layer's render range. + // It can happen that the layer has no associated ranges in this wide tile in + // case they have been cleared by setting a new wide tile background, for example + // when filling a full-tile opaque solid color. + let Some(ranges) = wtile.layer_cmd_ranges.get(&layer_id) else { + return; + }; + + let mut cmd_idx = ranges.render_range.start; + while cmd_idx < ranges.render_range.end { + let cmd: &Cmd = &wtile.cmds[cmd_idx]; + + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + + // Special handling for filtered layer composition. + // Filtered layers have already been rendered and stored in layer_manager. + // Here we composite them into the current buffer, with special handling for clipping. + if let Cmd::PushBuf(LayerKind::Filtered(child_layer_id), _) = cmd { + // Unlike above, the unwrap is safe here because as long as the filtered layer + // is referenced in the wide tile, it must have associated layer ranges. + let filtered_ranges = wtile.layer_cmd_ranges.get(child_layer_id).unwrap(); + + // Check what comes after the filtered layer push to determine clipping state + match wtile.cmds.get(cmd_idx + 1) { + // Zero-clip region: tile is completely outside the clip path. + // The layer was already rendered for filtering, but we skip compositing + // since this tile is entirely clipped out. + // (PushZeroClip only appears for clipped filter layers) + // See https://github.com/linebender/vello/pull/1541/ for why we + // add the ID check. + Some(Cmd::PushZeroClip(id)) if *id == *child_layer_id => { + // If we have a zero-clip, it means that the whole layer should not be drawn. + // Therefore, we want to skip to the very end so that only `PopBuf` will + // be run. Therefore, we jump to `filtered_ranges.full_range.end - 1`. + cmd_idx = filtered_ranges.full_range.end - 1; + continue; + } + + // Partial clip: push the clip buffer, then composite the filtered layer + Some(Cmd::PushBuf(LayerKind::Clip(id), _)) if *id == *child_layer_id => { + fine.run_cmd( + &wtile.cmds[cmd_idx + 1], + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + cmd_idx += 1; + + if let Some(mut region) = + layer_manager.layer_tile_region_mut(*child_layer_id, x, y) + { + fine.unpack(&mut region); + } + } + + // No clip or fully inside clip: composite the filtered layer directly + _ => { + if let Some(mut region) = + layer_manager.layer_tile_region_mut(*child_layer_id, x, y) + { + fine.unpack(&mut region); + } + } + } + + // Skip past the filtered layer's internal commands, as they were already + // rendered when the FilterLayer node was processed earlier. + cmd_idx = filtered_ranges.render_range.end.max(cmd_idx + 1); + } else { + cmd_idx += 1; + } + } + } + + /// Simple rasterization path for scenes without filter effects. + /// + /// This directly processes each tile's commands without maintaining intermediate + /// layer buffers. All rendering happens in a single pass directly to the output buffer. + /// This is more efficient than the filter-aware path when no filters are present. + fn rasterize_simple>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut regions = Regions::new(width, height, buffer); + let mut fine = Fine::::new(simd); + + regions.update_regions(|region| { + let x = region.x; + let y = region.y; + + let wtile = self.wide.get(x, y); + fine.set_coords(x, y); + + // Clear to background and process all commands in order. + fine.clear(wtile.bg); + for cmd in &wtile.cmds { + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + } + + fine.pack(region); + }); + } + + /// Returns true if the scene contains any filter effects. + fn has_filters(&self) -> bool { + self.render_graph.has_filters() + } + + /// Composites at an offset using f32 precision (high quality). + #[cfg(feature = "f32_pipeline")] + fn composite_at_offset_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.composite_at_offset_with::<_, F32Kernel>( + simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver + )); + } + + /// Composites at an offset using u8 precision (fast). + #[cfg(feature = "u8_pipeline")] + fn composite_at_offset_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.composite_at_offset_with::<_, U8Kernel>( + simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver + )); + } + + /// Core implementation for compositing at an offset. + /// + /// Composites tiles sequentially, writing directly to the destination buffer + /// at the specified offset. + fn composite_at_offset_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut regions = Regions::new_at_offset( + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + buffer, + ); + let mut fine = Fine::::new(simd); + + regions.update_regions(|region| { + let x = region.x; + let y = region.y; + + let wtile = self.wide.get(x, y); + fine.set_coords(x, y); + + // Unpack existing pixel data from the region instead of clearing, + // so that rendering composites onto the existing pixmap contents. + fine.unpack(region); + for cmd in &wtile.cmds { + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + } + fine.pack(region); + }); + } +} + +impl Dispatcher for SingleThreadedDispatcher { + fn wide(&self) -> &Wide { + &self.wide + } + + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Convert path to coverage strips. + self.strip_generator.generate_filled_path( + path, + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Convert stroked path to coverage strips. + self.strip_generator.generate_stroked_path( + path, + stroke, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Generate strips directly for the rectangle (bypasses path processing). + self.strip_generator.generate_filled_rect_fast( + rect, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ) { + // Allocate a new unique layer ID. + self.layer_id_next += 1; + + // Generate clip coverage if a clip path is provided. + let clip = if let Some(c) = clip_path { + self.strip_generator.generate_filled_path( + c, + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + Some(self.strip_storage.strips.as_slice()) + } else { + None + }; + + // Push the layer onto the coarse tile stack and update render graph. + self.wide.push_layer( + self.layer_id_next, + clip, + blend_mode, + mask, + opacity, + filter, + transform, + &mut self.render_graph, + 0, + ); + } + + fn pop_layer(&mut self) { + // Pop the current layer and update render graph. + self.wide.pop_layer(&mut self.render_graph); + } + + fn reset(&mut self) { + // Clear all rendering state to prepare for a new scene. + self.wide.reset(); + self.clip_context.reset(); + self.strip_generator.reset(); + self.strip_storage.clear(); + self.render_graph.clear(); + self.layer_id_next = 0; + + // Recreate root node as node 0 (required for proper execution order). + let root_node = self.render_graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: WideTilesBbox::new(0, 0, self.wide.width_tiles(), self.wide.height_tiles()), + }); + debug_assert_eq!(root_node, 0, "Root node must be node 0"); + + // Reset layer ID counter. + self.layer_id_next = 0; + } + + fn flush(&mut self, _encoded_paints: &[EncodedPaint]) { + // No-op for single-threaded dispatcher (no work queue to flush). + } + + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + // If only the u8 pipeline is enabled, then use it + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + + // If only the f32 pipeline is enabled, then use it + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + + // If both pipelines are enabled, select precision based on render mode parameter. + #[cfg(all(feature = "u8_pipeline", feature = "f32_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + // Use u8 precision for faster rendering. + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + RenderMode::OptimizeQuality => { + // Use f32 precision for higher quality. + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + } + + #[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] + { + // This case never gets hit because there is a compile_error in the root. + // But have this code disables some warnings and makes the compile error easier to read + let _ = ( + buffer, + render_mode, + width, + height, + encoded_paints, + image_resolver, + ); + } + } + + fn composite_at_offset( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + render_mode: RenderMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.composite_at_offset_u8( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.composite_at_offset_f32( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + + #[cfg(all(feature = "u8_pipeline", feature = "f32_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + self.composite_at_offset_u8( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + RenderMode::OptimizeQuality => { + self.composite_at_offset_f32( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + } + + #[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] + { + let _ = ( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + render_mode, + encoded_paints, + image_resolver, + ); + } + } + + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.clip_context.push_clip( + path, + &mut self.strip_generator, + fill_rule, + transform, + aliasing_threshold, + ); + } + + fn pop_clip_path(&mut self) { + self.clip_context.pop_clip(); + } +} + +/// Saves a filtered pixmap to disk for debugging purposes. +/// Only available in debug builds with `std` and `png` features enabled. +#[allow( + dead_code, + reason = "useful debug utility, can be enabled by uncommenting the call site" +)] +#[cfg(all(debug_assertions, feature = "std", feature = "png"))] +fn save_filtered_layer_debug(pixmap: &Pixmap, layer_id: u32) { + use std::path::PathBuf; + + let diffs_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/diffs"); + let _ = std::fs::create_dir_all(&diffs_path); + let filename = diffs_path.join(alloc::format!("filtered_layer_{}.png", layer_id)); + + if let Ok(png_data) = pixmap.clone().into_png() { + let _ = std::fs::write(&filename, &png_data); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kurbo::Rect; + use vello_common::color::palette::css::BLUE; + use vello_common::kurbo::Shape; + use vello_common::paint::PremulColor; + + /// Verifies that `reset()` properly clears all internal buffers and state. + /// + /// This is important to ensure that a dispatcher can be reused for multiple + /// rendering passes without accumulating stale data from previous frames. + #[test] + fn buffers_cleared_on_reset() { + let mut dispatcher = SingleThreadedDispatcher::new(100, 100, Level::new()); + + // Render a simple shape to populate internal buffers. + dispatcher.fill_path( + &Rect::new(0.0, 0.0, 50.0, 50.0).to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + Paint::Solid(PremulColor::from_alpha_color(BLUE)), + BlendMode::default(), + None, + None, + &[], + ); + + // Ensure there is data to clear. + assert!(!dispatcher.strip_storage.alphas.is_empty()); + assert!(!dispatcher.wide.get(0, 0).cmds.is_empty()); + + dispatcher.reset(); + + // Verify all buffers are cleared. + assert!(dispatcher.strip_storage.alphas.is_empty()); + assert!(dispatcher.wide.get(0, 0).cmds.is_empty()); + assert_eq!(dispatcher.layer_id_next, 0); + } +} diff --git a/vendor/vello_cpu/src/filter/drop_shadow.rs b/vendor/vello_cpu/src/filter/drop_shadow.rs new file mode 100644 index 0000000000..83f0cf3be6 --- /dev/null +++ b/vendor/vello_cpu/src/filter/drop_shadow.rs @@ -0,0 +1,312 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Drop shadow filter implementation. +//! +//! This implements the feDropShadow primitive from SVG Filter Effects 2. +//! The drop shadow effect is a shorthand for a commonly used sequence of filter operations: +//! 1. Extract alpha channel +//! 2. Offset the alpha +//! 3. Blur the offset alpha +//! 4. Composite shadow color with blurred alpha +//! 5. Composite shadow with original graphic +//! +//! @see + +use super::FilterEffect; +use super::gaussian_blur::apply_blur; +use super::shift::offset_pixels; +use crate::layer_manager::LayerManager; +use vello_common::color::{AlphaColor, Srgb}; +use vello_common::filter::drop_shadow::DropShadow; +use vello_common::filter_effects::EdgeMode; +use vello_common::peniko::color::PremulRgba8; +#[cfg(not(feature = "std"))] +use vello_common::peniko::kurbo::common::FloatFuncs as _; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for DropShadow { + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + apply_drop_shadow( + pixmap, + self.dx, + self.dy, + self.std_deviation, + self.n_decimations, + &self.kernel[..usize::from(self.kernel_size)], + self.color, + self.edge_mode, + layer_manager, + ); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // TODO: Currently only lowp is implemented and used for highp as well. + // This needs to be updated to use proper high-precision arithmetic. + Self::execute_lowp(self, pixmap, layer_manager); + } +} + +/// Apply drop shadow effect. +/// +/// This is the main entry point that splits the drop shadow operation into well-defined steps: +/// 1. Offset the shadow pixels +/// 2. Blur the already-offset shadow +/// 3. Apply shadow color and composite with original +fn apply_drop_shadow( + pixmap: &mut Pixmap, + dx: f32, + dy: f32, + std_deviation: f32, + n_decimations: usize, + kernel: &[f32], + color: AlphaColor, + edge_mode: EdgeMode, + layer_manager: &mut LayerManager, +) { + // Clone pixmap to create shadow buffer + let mut shadow_pixmap = pixmap.clone(); + + // Step 1: Offset the shadow pixels + offset_pixels(&mut shadow_pixmap, dx, dy); + + // Step 2: Blur the already-offset shadow + if std_deviation > 0.0 { + let scratch = + layer_manager.get_scratch_buffer(shadow_pixmap.width(), shadow_pixmap.height()); + apply_blur( + &mut shadow_pixmap, + scratch, + n_decimations, + kernel, + edge_mode, + ); + } + + // Step 3: Apply shadow color and composite with original + compose_shadow_direct(&shadow_pixmap, pixmap, color); +} + +/// Apply shadow color and composite with original. +/// +/// The shadow has already been offset and blurred, so this simply applies +/// the shadow color to the alpha channel and composites using source-over. +fn compose_shadow_direct(shadow: &Pixmap, dst: &mut Pixmap, color: AlphaColor) { + let width = dst.width(); + let height = dst.height(); + + // Precompute shadow color components + let shadow_r = (color.components[0] * 255.0).round() as u8; + let shadow_g = (color.components[1] * 255.0).round() as u8; + let shadow_b = (color.components[2] * 255.0).round() as u8; + + for y in 0..height { + for x in 0..width { + // Sample alpha directly (shadow is already offset) + let alpha = shadow.sample(x, y).a; + + // Apply shadow color to alpha + let shadow_alpha = (u8_to_norm(alpha) * color.components[3]).min(1.0); + let final_alpha = norm_to_u8(shadow_alpha); + + // Premultiply RGB by alpha as required by PremulRgba8 + let alpha_u16 = u16::from(final_alpha); + let premultiply = |channel: u8| ((u16::from(channel) * alpha_u16) / 255) as u8; + + let colored_shadow = PremulRgba8 { + r: premultiply(shadow_r), + g: premultiply(shadow_g), + b: premultiply(shadow_b), + a: final_alpha, + }; + + // Read original and composite: original over shadow + let original_pixel = dst.sample(x, y); + let result = compose_src_over(original_pixel, colored_shadow); + + dst.set_pixel(x, y, result); + } + } +} + +/// Composite two pixels using Porter-Duff "source over" operator. +/// +/// Composes the source pixel over the destination pixel using premultiplied +/// alpha blending. Returns the composited result. +/// +/// Formula for premultiplied colors: `result = src + dst * (1 - src_alpha)` +fn compose_src_over(src: PremulRgba8, dst: PremulRgba8) -> PremulRgba8 { + let src_a = u8_to_norm(src.a); + + PremulRgba8 { + r: src_over_channel(src.r, dst.r, src_a), + g: src_over_channel(src.g, dst.g, src_a), + b: src_over_channel(src.b, dst.b, src_a), + a: src_over_channel(src.a, dst.a, src_a), + } +} + +/// Blend a single channel using Porter-Duff "source over" operator. +/// +/// For premultiplied colors, the formula is: `result = src + dst * (1 - src_alpha)` +#[inline] +fn src_over_channel(src: u8, dst: u8, src_alpha: f32) -> u8 { + let result = u8_to_norm(src) + u8_to_norm(dst) * (1.0 - src_alpha); + norm_to_u8(result) +} + +/// Convert a u8 color component (0-255) to normalized f32 (0.0-1.0). +#[inline] +fn u8_to_norm(value: u8) -> f32 { + value as f32 / 255.0 +} + +/// Convert a normalized f32 (0.0-1.0) to u8 color component (0-255). +#[inline] +fn norm_to_u8(value: f32) -> u8 { + (value * 255.0).round() as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::color::Srgb; + + /// Test `u8_to_norm` conversion. + #[test] + fn test_u8_to_norm() { + assert_eq!(u8_to_norm(0), 0.0); + assert!((u8_to_norm(255) - 1.0).abs() < 1e-6); + } + + /// Test `norm_to_u8` conversion. + #[test] + fn test_norm_to_u8() { + assert_eq!(norm_to_u8(0.0), 0); + assert_eq!(norm_to_u8(1.0), 255); + assert_eq!(norm_to_u8(0.5), 128); // 0.5 * 255 = 127.5 → 128 + } + + /// Test round-trip conversion u8 → norm → u8. + #[test] + fn test_conversion_roundtrip() { + for value in [0, 1, 50, 127, 128, 200, 254, 255] { + let normalized = u8_to_norm(value); + let back = norm_to_u8(normalized); + assert_eq!(back, value); + } + } + + /// Test Porter-Duff source-over with fully opaque source. + #[test] + fn test_compose_src_over_opaque_source() { + let src = PremulRgba8 { + r: 255, + g: 0, + b: 0, + a: 255, + }; // Opaque red + let dst = PremulRgba8 { + r: 0, + g: 255, + b: 0, + a: 255, + }; // Opaque green + + let result = compose_src_over(src, dst); + // Opaque source should completely cover destination + assert_eq!(result.r, 255); + assert_eq!(result.g, 0); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } + + /// Test Porter-Duff source-over with transparent source. + #[test] + fn test_compose_src_over_transparent_source() { + let src = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let dst = PremulRgba8 { + r: 0, + g: 255, + b: 0, + a: 255, + }; + + let result = compose_src_over(src, dst); + // Transparent source should leave destination unchanged + assert_eq!(result.r, 0); + assert_eq!(result.g, 255); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } + + /// Test Porter-Duff source-over with semi-transparent source. + #[test] + fn test_compose_src_over_semi_transparent() { + let src = PremulRgba8 { + r: 128, + g: 0, + b: 0, + a: 128, + }; // 50% red (premul) + let dst = PremulRgba8 { + r: 0, + g: 128, + b: 0, + a: 128, + }; // 50% green (premul) + + let result = compose_src_over(src, dst); + // Result should blend src + dst*(1-src_alpha) + // r: 128 + 0*(1-0.5) = 128 + // g: 0 + 128*0.5 = 64 + // a: 128 + 128*0.5 = 192 + assert_eq!( + result, + PremulRgba8 { + r: 128, + g: 64, + b: 0, + a: 192, + } + ); + } + + /// Test `compose_shadow_direct` applies color correctly. + #[test] + fn test_compose_shadow_color() { + let mut shadow_pixmap = Pixmap::new(2, 2); + let mut dst_pixmap = Pixmap::new(2, 2); + + // Shadow has alpha=255 at (0,0) + shadow_pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 255, + }, + ); + + let shadow_color = AlphaColor { + components: [1.0, 0.0, 0.0, 1.0], // Red + cs: std::marker::PhantomData::, + }; + + compose_shadow_direct(&shadow_pixmap, &mut dst_pixmap, shadow_color); + + // Shadow at (0,0) should be red + let result = dst_pixmap.sample(0, 0); + assert_eq!(result.r, 255); + assert_eq!(result.g, 0); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } +} diff --git a/vendor/vello_cpu/src/filter/flood.rs b/vendor/vello_cpu/src/filter/flood.rs new file mode 100644 index 0000000000..1d300debac --- /dev/null +++ b/vendor/vello_cpu/src/filter/flood.rs @@ -0,0 +1,89 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Flood filter implementation. + +use super::FilterEffect; +use crate::layer_manager::LayerManager; +use vello_common::filter::flood::Flood; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for Flood { + fn execute_lowp(&self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) { + pixmap.data_mut().fill(self.color.premultiply().to_rgba8()); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) { + pixmap.data_mut().fill(self.color.premultiply().to_rgba8()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::AlphaColor; + use crate::layer_manager::LayerManager; + use vello_common::color::{PremulRgba8, Srgb}; + + /// Test flood with semi-transparent color - verifies correct premultiplication. + #[test] + fn test_flood_semi_transparent_lowp() { + let mut pixmap = Pixmap::new(2, 2); + let mut layer_manager = LayerManager::new(); + + // Semi-transparent white (50% alpha) + let color = AlphaColor { + components: [1.0, 1.0, 1.0, 0.5], + cs: std::marker::PhantomData::, + }; + let flood = Flood::new(color); + flood.execute_lowp(&mut pixmap, &mut layer_manager); + + // RGB should be premultiplied by alpha: 255 * 0.5 = 127-128 + for y in 0..2 { + for x in 0..2 { + let pixel = pixmap.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 128 + } + ); + } + } + } + + /// Test flood highp with semi-transparent color - verifies correct premultiplication. + #[test] + fn test_flood_semi_transparent_highp() { + let mut pixmap = Pixmap::new(2, 2); + let mut layer_manager = LayerManager::new(); + + // Semi-transparent white (50% alpha) + let color = AlphaColor { + components: [1.0, 1.0, 1.0, 0.5], + cs: std::marker::PhantomData::, + }; + let flood = Flood::new(color); + flood.execute_highp(&mut pixmap, &mut layer_manager); + + // RGB should be premultiplied by alpha: 255 * 0.5 = 127-128 + for y in 0..2 { + for x in 0..2 { + let pixel = pixmap.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 128 + } + ); + } + } + } +} diff --git a/vendor/vello_cpu/src/filter/gaussian_blur.rs b/vendor/vello_cpu/src/filter/gaussian_blur.rs new file mode 100644 index 0000000000..2d9bd1b2ed --- /dev/null +++ b/vendor/vello_cpu/src/filter/gaussian_blur.rs @@ -0,0 +1,1147 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Gaussian blur filter implementation using multi-scale separable convolution. +//! +//! This implementation uses a multi-scale approach for efficient blurring: +//! - **Small blurs** (σ ≤ 2): Direct separable convolution at full resolution +//! - **Large blurs** (σ > 2): Iterative downsample → blur → upsample pyramid +//! +//! The algorithm automatically determines the optimal number of decimation levels +//! using variance analysis. Each 2× decimation applies a \[1,3,3,1\]/8 binomial filter +//! (adding variance = 3.0), then downsamples, reducing the remaining blur work needed. +//! This exploits the variance additivity property: `σ²_total = σ²_downsample + σ²_blur`. +//! +//! **Variance Addition Reference**: The convolution of two Gaussians with variances σ₁² +//! and σ₂² produces a Gaussian with variance σ₁² + σ₂². This fundamental property comes +//! from probability theory and applies to Gaussian convolution in image processing. See: +//! - Torralba & Freeman, "Foundations of Computer Vision" (MIT Press), Section 2.2: +//! + +use super::FilterEffect; +use crate::layer_manager::LayerManager; +use vello_common::filter::gaussian_blur::{DecimationSizer, GaussianBlur}; +use vello_common::filter_effects::EdgeMode; +use vello_common::peniko::color::PremulRgba8; +#[cfg(not(feature = "std"))] +use vello_common::peniko::kurbo::common::FloatFuncs as _; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for GaussianBlur { + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // No blur if std_deviation is zero or negative + if self.std_deviation <= 0.0 { + return; + } + + let scratch = layer_manager.get_scratch_buffer(pixmap.width(), pixmap.height()); + apply_blur( + pixmap, + scratch, + self.n_decimations, + &self.kernel[..usize::from(self.kernel_size)], + self.edge_mode, + ); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // TODO: Currently only lowp is implemented and used for highp as well. + // This needs to be updated to use proper high-precision arithmetic. + Self::execute_lowp(self, pixmap, layer_manager); + } +} + +/// Apply Gaussian blur using multi-scale decimation and upsampling. +/// +/// Uses a precomputed decimation plan and kernel for optimal performance. +/// Operates in-place using a single pixmap buffer with logical dimension tracking +/// to minimize memory allocations. For `n_decimations=0`, applies direct convolution. +/// +/// The `scratch` buffer is used for separable convolution and must be at least as +/// large as the source pixmap. +pub(crate) fn apply_blur( + pixmap: &mut Pixmap, + scratch: &mut Pixmap, + n_decimations: usize, + kernel: &[f32], + edge_mode: EdgeMode, +) { + let radius = (kernel.len() / 2) as u8; + let width = pixmap.width(); + let height = pixmap.height(); + + // Small blur: apply direct convolution at full resolution + if n_decimations == 0 { + convolve(pixmap, scratch, width, height, kernel, radius, edge_mode); + return; + } + + // Track logical dimensions through decimation (physical buffer stays the same size) + let mut sizer = DecimationSizer::new(width, height); + + // Downsample n times (each step reduces resolution by 2×) + for _ in 0..n_decimations { + let (w, h) = sizer.current(); + downscale(pixmap, w, h, edge_mode); + sizer.downscale(); + } + + // Apply the reduced blur at the coarsest resolution + let (w, h) = sizer.current(); + convolve(pixmap, scratch, w, h, kernel, radius, edge_mode); + + // Upsample back to original resolution (each step doubles resolution by 2×) + for _ in 0..n_decimations { + let (w, h) = sizer.current(); + upscale(pixmap, w, h, edge_mode); + sizer.upscale(); + } + + debug_assert_eq!( + sizer.current(), + (width, height), + "Final dimensions should match original" + ); +} + +/// Apply separable Gaussian convolution with logical dimensions. +/// +/// Performs horizontal blur followed by vertical blur. Works with a logical view +/// of the pixmap, using only the top-left region defined by width × height. +/// The `temp` buffer is provided by the caller to avoid allocations. +pub(crate) fn convolve( + src: &mut Pixmap, + scratch: &mut Pixmap, + width: u16, + height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + convolve_x(src, scratch, width, height, kernel, radius, edge_mode); + convolve_y(scratch, src, width, height, kernel, radius, edge_mode); +} + +/// Apply horizontal blur pass (1D convolution along x-axis). +/// +/// For each output pixel, computes a weighted sum of horizontally neighboring pixels +/// using the Gaussian kernel. Handles edge cases according to the specified edge mode. +/// Writes results to a destination buffer to avoid overwriting source data. +pub(crate) fn convolve_x( + src: &Pixmap, + dst: &mut Pixmap, + src_width: u16, + src_height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + for x in 0..src_width { + let mut rgba = [0.0_f32; 4]; + + // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[x+j-radius]) + for (j, &k) in kernel.iter().enumerate() { + #[expect( + clippy::cast_possible_wrap, + reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`" + )] + let j = j as i32; + let src_x = x as i32 + j - radius as i32; + let p = sample_x(src, src_x, y, src_width, edge_mode); + + rgba[0] += p.r as f32 * k; + rgba[1] += p.g as f32 * k; + rgba[2] += p.b as f32 * k; + rgba[3] += p.a as f32 * k; + } + + // Convert back to u8 with rounding + dst.set_pixel( + x, + y, + PremulRgba8 { + r: rgba[0].round() as u8, + g: rgba[1].round() as u8, + b: rgba[2].round() as u8, + a: rgba[3].round() as u8, + }, + ); + } + } +} + +/// Apply vertical blur pass (1D convolution along y-axis). +/// +/// For each output pixel, computes a weighted sum of vertically neighboring pixels +/// using the Gaussian kernel. Handles edge cases according to the specified edge mode. +/// Writes results to a destination buffer to avoid overwriting source data. +pub(crate) fn convolve_y( + src: &Pixmap, + dst: &mut Pixmap, + src_width: u16, + src_height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + for x in 0..src_width { + let mut rgba = [0.0_f32; 4]; + + // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[y+j-radius]) + for (j, &k) in kernel.iter().enumerate() { + #[expect( + clippy::cast_possible_wrap, + reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`" + )] + let j = j as i32; + let src_y = y as i32 + j - radius as i32; + let p = sample_y(src, x, src_y, src_height, edge_mode); + + rgba[0] += p.r as f32 * k; + rgba[1] += p.g as f32 * k; + rgba[2] += p.b as f32 * k; + rgba[3] += p.a as f32 * k; + } + + // Convert back to u8 with rounding + dst.set_pixel( + x, + y, + PremulRgba8 { + r: rgba[0].round() as u8, + g: rgba[1].round() as u8, + b: rgba[2].round() as u8, + a: rgba[3].round() as u8, + }, + ); + } + } +} + +/// Downsample image by 2x using separable \[1,3,3,1\]/8 binomial filter. +/// +/// Performs horizontal and vertical decimation in sequence. Returns the new +/// logical dimensions (ceil(width/2), ceil(height/2)). +pub(crate) fn downscale( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + edge_mode: EdgeMode, +) -> (u16, u16) { + let dst_width = src_width.div_ceil(2); + let dst_height = src_height.div_ceil(2); + downscale_x(src, src_width, src_height, dst_width, edge_mode); + // We can pass `dst_width` instead of `src_width` here, since we already decimated + // horizontally. + downscale_y(src, dst_width, src_height, dst_height, edge_mode); + (dst_width, dst_height) +} + +/// Horizontal decimation pass using \[1,3,3,1\]/8 filter. +/// +/// Reduces width by 2x while applying a binomial blur kernel. The \[1,3,3,1\] weights +/// approximate a Gaussian and contribute variance=0.75 before the 2x downsampling. +fn downscale_x( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + dst_width: u16, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + // Sliding window: maintains 2 previous pixels (x0, x1) to form 4-tap filter + // with current pixels (x2, x3). Start with sample at -1 for implicit left padding. + let mut p0 = sample_x(src, -1, y, src_width, edge_mode); + let mut p1 = sample_x(src, 0, y, src_width, edge_mode); + + for x in 0..dst_width { + // Sample 4 horizontally adjacent pixels for [1,3,3,1] kernel + // Pattern: [x*2-1, x*2, x*2+1, x*2+2] + let src_x = (x * 2) as i32; + let p2 = sample_x(src, src_x + 1, y, src_width, edge_mode); + let p3 = sample_x(src, src_x + 2, y, src_width, edge_mode); + + // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8 + src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3)); + + // Advance window: previous p2,p3 become next p0,p1 + p0 = p2; + p1 = p3; + } + } +} + +/// Vertical decimation pass using \[1,3,3,1\]/8 filter. +/// +/// Reduces logical height by 2x while applying a binomial blur kernel. +/// Operates in-place by writing to the beginning of the same buffer. +fn downscale_y( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + dst_height: u16, + edge_mode: EdgeMode, +) { + for x in 0..src_width { + // Sliding window: maintains 2 previous pixels (y0, y1) to form 4-tap filter + // with current pixels (y2, y3). Start with sample at -1 for implicit top padding. + let mut p0 = sample_y(src, x, -1, src_height, edge_mode); + let mut p1 = sample_y(src, x, 0, src_height, edge_mode); + + for y in 0..dst_height { + // Sample 4 vertically adjacent pixels for [1,3,3,1] kernel + // Pattern: [y*2-1, y*2, y*2+1, y*2+2] + let src_y = (y * 2) as i32; + let p2 = sample_y(src, x, src_y + 1, src_height, edge_mode); + let p3 = sample_y(src, x, src_y + 2, src_height, edge_mode); + + // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8 + src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3)); + + // Advance window: previous p2,p3 become next p0,p1 + p0 = p2; + p1 = p3; + } + } +} + +/// Upsample a pixmap by 2x using linear interpolation with [0.75, 0.25] weights. +/// +/// Uses separable passes: horizontal doubling followed by vertical doubling. +/// +/// ## Phase Alignment Theory +/// +/// The downsampling \[1,3,3,1\]/8 filter creates a half-pixel offset. Its center of mass +/// is at position `2k + 0.5`, not `2k`. This means each downsampled pixel at discrete +/// position `k` represents a sample at continuous position `2k + 0.5`. +/// +/// When upsampling, we reconstruct pixels at positions `2k` and `2k+1` from downsampled +/// pixels whose centers are at `..., 2k-1.5, 2k+0.5, 2k+2.5, ...` +/// +/// **Interpolation weights** are derived from linear interpolation based on distances: +/// - Position `2k`: distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k-1.5` +/// → weights: 0.75×pixel\[k\] + 0.25×pixel\[k-1\] +/// - Position `2k+1`: distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k+2.5` +/// → weights: 0.75×pixel\[k\] + 0.25×pixel\[k+1\] +pub(crate) fn upscale( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + edge_mode: EdgeMode, +) -> (u16, u16) { + let dst_width = src_width * 2; + let dst_height = src_height * 2; + upscale_x(src, src_width, src_height, edge_mode); + upscale_y(src, dst_width, src_height, edge_mode); + (dst_width, dst_height) +} + +/// Horizontal upsampling pass using [0.75, 0.25] interpolation with logical dimensions. +/// +/// Doubles the logical width using phase-aligned interpolation. Each input pixel +/// generates two output pixels with different weights based on their distance from +/// the downsampled pixel's center position. +/// Operates in-place by processing backwards to avoid overwriting source data. +fn upscale_x(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) { + // Process backwards (right to left) to avoid overwriting source data + for y in 0..src_height { + // Maintain sliding window of three pixels: prev, current, next + // This allows us to compute both output pixels that depend on current pixel x + let mut p0 = sample_x(src, src_width as i32, y, src_width, edge_mode); + let mut p1 = sample_x(src, src_width as i32 - 1, y, src_width, edge_mode); + + for x in (0..src_width).rev() { + let src_x = x as i32; + let p2 = sample_x(src, src_x - 1, y, src_width, edge_mode); + + // Generate two output pixels per input with phase-aligned interpolation: + // output[2x] = 0.25×p2 + 0.75×p1 (position 2x is 0.5 from center at 2x+0.5) + // output[2x+1] = 0.75×p1 + 0.25×p0 (position 2x+1 is 0.5 from center at 2x+0.5) + let dst_x = x * 2; + src.set_pixel(dst_x, y, interpolate_25_75(p2, p1)); + src.set_pixel(dst_x + 1, y, interpolate_75_25(p1, p0)); + + // Advance sliding window for next iteration + p0 = p1; + p1 = p2; + } + } +} + +/// Vertical upsampling pass using [0.75, 0.25] interpolation with logical dimensions. +/// +/// Doubles the logical height using phase-aligned interpolation. Each input pixel +/// generates two output pixels with different weights based on their distance from +/// the downsampled pixel's center position. +/// Operates in-place by processing backwards to avoid overwriting source data. +fn upscale_y(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) { + // Process backwards (bottom to top) to avoid overwriting source data + for x in 0..src_width { + // Maintain sliding window of three pixels: prev, current, next + // This allows us to compute both output pixels that depend on current pixel y + let mut p0 = sample_y(src, x, src_height as i32, src_height, edge_mode); + let mut p1 = sample_y(src, x, src_height as i32 - 1, src_height, edge_mode); + + for y in (0..src_height).rev() { + let src_y = y as i32; + let p2 = sample_y(src, x, src_y - 1, src_height, edge_mode); + + // Generate two output rows per input with phase-aligned interpolation: + // output[2y] = 0.25×p2 + 0.75×p1 (position 2y is 0.5 from center at 2y+0.5) + // output[2y+1] = 0.75×p1 + 0.25×p0 (position 2y+1 is 0.5 from center at 2y+0.5) + let dst_y = y * 2; + src.set_pixel(x, dst_y, interpolate_25_75(p2, p1)); + src.set_pixel(x, dst_y + 1, interpolate_75_25(p1, p0)); + + // Advance sliding window for next iteration + p0 = p1; + p1 = p2; + } + } +} + +/// Transparent black pixel constant. +const TRANSPARENT_BLACK: PremulRgba8 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, +}; + +/// Sample a pixel with edge mode handling for horizontal sampling. +#[inline(always)] +fn sample_x(src: &Pixmap, x: i32, y: u16, width: u16, edge_mode: EdgeMode) -> PremulRgba8 { + sample(x, width, edge_mode, |src_x| src.sample(src_x, y)) +} + +/// Sample a pixel with edge mode handling for vertical sampling. +#[inline(always)] +fn sample_y(src: &Pixmap, x: u16, y: i32, height: u16, edge_mode: EdgeMode) -> PremulRgba8 { + sample(y, height, edge_mode, |src_y| src.sample(x, src_y)) +} + +/// Sample a pixel with edge mode handling (generic implementation). +/// +/// Handles both horizontal and vertical sampling based on the provided closure. +/// The `sample_fn` closure receives the clamped/extended coordinate and returns the pixel. +/// For `EdgeMode::None`, returns transparent black if the coordinate is out of bounds. +#[inline(always)] +fn sample(coord: i32, size: u16, edge_mode: EdgeMode, sample_fn: F) -> PremulRgba8 +where + F: FnOnce(u16) -> PremulRgba8, +{ + // For EdgeMode::None, return transparent black if out of bounds + if edge_mode == EdgeMode::None && (coord < 0 || coord >= size as i32) { + return TRANSPARENT_BLACK; + } + let extended_coord = extend(coord, size, edge_mode); + sample_fn(extended_coord) +} + +/// Extend a coordinate beyond image boundaries according to the edge mode. +/// +/// Transforms out-of-bounds coordinates for sampling: clamped, wrapped, or mirrored +/// depending on the mode. For `EdgeMode::None`, the coordinate is guaranteed to be +/// in-bounds (already checked by caller, which returns transparent black for out-of-bounds). +#[inline(always)] +fn extend(coord: i32, size: u16, edge_mode: EdgeMode) -> u16 { + match edge_mode { + EdgeMode::Duplicate => { + // Clamp to image bounds: pixels outside use nearest edge pixel + coord.clamp(0, size as i32 - 1) as u16 + } + EdgeMode::None => { + // Coordinate is already validated as in-bounds by caller + coord as u16 + } + EdgeMode::Wrap => { + // Wrap around using modulo: image tiles infinitely + let mut c = coord % size as i32; + if c < 0 { + c += size as i32; + } + c as u16 + } + EdgeMode::Mirror => { + // Mirror at boundaries: image reflects across edges + let period = size as i32 * 2; + let mut c = coord % period; + if c < 0 { + c += period; + } + if c >= size as i32 { + c = period - c - 1; + } + c as u16 + } + } +} + +/// Blend 4 RGBA pixels using \[1,3,3,1\]/8 binomial weights. +/// +/// Computes `(p0 + 3×p1 + 3×p2 + p3) / 8` using efficient integer arithmetic (right shift). +/// This binomial pattern approximates a Gaussian and contributes variance=0.75 to the blur. +/// Adds 4 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn decimate_weighted( + p0: PremulRgba8, + p1: PremulRgba8, + p2: PremulRgba8, + p3: PremulRgba8, +) -> PremulRgba8 { + let r = ((p0.r as u32 + p1.r as u32 * 3 + p2.r as u32 * 3 + p3.r as u32 + 4) >> 3) as u8; + let g = ((p0.g as u32 + p1.g as u32 * 3 + p2.g as u32 * 3 + p3.g as u32 + 4) >> 3) as u8; + let b = ((p0.b as u32 + p1.b as u32 * 3 + p2.b as u32 * 3 + p3.b as u32 + 4) >> 3) as u8; + let a = ((p0.a as u32 + p1.a as u32 * 3 + p2.a as u32 * 3 + p3.a as u32 + 4) >> 3) as u8; + PremulRgba8 { r, g, b, a } +} + +/// Blend 2 RGBA pixels using [0.25, 0.75] weights (right-weighted interpolation). +/// +/// Used for upsampling to generate the second of two output pixels. +/// Favors the right/next pixel (p1) with 75% weight. +/// Adds 2 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn interpolate_25_75(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 { + let r = ((p0.r as u32 + p1.r as u32 * 3 + 2) >> 2) as u8; + let g = ((p0.g as u32 + p1.g as u32 * 3 + 2) >> 2) as u8; + let b = ((p0.b as u32 + p1.b as u32 * 3 + 2) >> 2) as u8; + let a = ((p0.a as u32 + p1.a as u32 * 3 + 2) >> 2) as u8; + PremulRgba8 { r, g, b, a } +} + +/// Blend 2 RGBA pixels using [0.75, 0.25] weights. +/// +/// Computes `0.75×p0 + 0.25×p1` using efficient integer arithmetic. +/// Used during upsampling for positions closer to the first pixel. +/// Adds 2 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn interpolate_75_25(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 { + let r = ((p0.r as u32 * 3 + p1.r as u32 + 2) >> 2) as u8; + let g = ((p0.g as u32 * 3 + p1.g as u32 + 2) >> 2) as u8; + let b = ((p0.b as u32 * 3 + p1.b as u32 + 2) >> 2) as u8; + let a = ((p0.a as u32 * 3 + p1.a as u32 + 2) >> 2) as u8; + PremulRgba8 { r, g, b, a } +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::filter::gaussian_blur::{ + MAX_KERNEL_SIZE, compute_gaussian_kernel, plan_decimated_blur, + }; + + /// Test edge extension with Duplicate mode. + #[test] + fn test_extend_duplicate() { + let size = 10; + + // In-bounds coordinate + assert_eq!(extend(5, size, EdgeMode::Duplicate), 5); + + // Below bounds: clamp to 0 + assert_eq!(extend(-1, size, EdgeMode::Duplicate), 0); + assert_eq!(extend(-10, size, EdgeMode::Duplicate), 0); + + // Above bounds: clamp to size-1 + assert_eq!(extend(10, size, EdgeMode::Duplicate), 9); + assert_eq!(extend(20, size, EdgeMode::Duplicate), 9); + } + + /// Test edge extension with Wrap mode. + #[test] + fn test_extend_wrap() { + let size = 10; + + // In-bounds: identity + assert_eq!(extend(5, size, EdgeMode::Wrap), 5); + + // Above bounds: wrap around + assert_eq!(extend(10, size, EdgeMode::Wrap), 0); + assert_eq!(extend(11, size, EdgeMode::Wrap), 1); + assert_eq!(extend(25, size, EdgeMode::Wrap), 5); + + // Below bounds: wrap from end + assert_eq!(extend(-1, size, EdgeMode::Wrap), 9); + assert_eq!(extend(-2, size, EdgeMode::Wrap), 8); + } + + /// Test edge extension with Mirror mode. + #[test] + fn test_extend_mirror() { + let size = 10; + + // In-bounds: identity + assert_eq!(extend(5, size, EdgeMode::Mirror), 5); + + // Just past boundary: mirror back + // For coord=10: period=20, c=10, c>=10 so c = 20-10-1 = 9 + assert_eq!(extend(10, size, EdgeMode::Mirror), 9); + // For coord=11: period=20, c=11, c>=10 so c = 20-11-1 = 8 + assert_eq!(extend(11, size, EdgeMode::Mirror), 8); + + // Full reflection cycle (period = 2*size = 20) + // For coord=19: c=19, c>=10 so c = 20-19-1 = 0 + assert_eq!(extend(19, size, EdgeMode::Mirror), 0); + // For coord=20: c=20%20=0, c<10 so c=0 + assert_eq!(extend(20, size, EdgeMode::Mirror), 0); + + // Negative coordinates + // For coord=-1: c=-1%20=-1, c<0 so c+=20 → c=19, c>=10 so c=20-19-1=0 + assert_eq!(extend(-1, size, EdgeMode::Mirror), 0); + // For coord=-2: c=-2%20=-2, c<0 so c+=20 → c=18, c>=10 so c=20-18-1=1 + assert_eq!(extend(-2, size, EdgeMode::Mirror), 1); + } + + /// Test edge extension with None mode (coordinate should pass through). + #[test] + fn test_extend_none() { + let size = 10; + // None mode just passes the coordinate as u16 + assert_eq!(extend(5, size, EdgeMode::None), 5); + assert_eq!(extend(9, size, EdgeMode::None), 9); + assert_eq!(extend(10, size, EdgeMode::None), 10); + assert_eq!(extend(11, size, EdgeMode::None), 11); + } + + /// Test [1,3,3,1]/8 decimation weights. + #[test] + fn test_decimate_weighted() { + let p0 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let p1 = PremulRgba8 { + r: 8, + g: 8, + b: 8, + a: 8, + }; + let p2 = PremulRgba8 { + r: 8, + g: 8, + b: 8, + a: 8, + }; + let p3 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + + // (0 + 3*8 + 3*8 + 0 + 4) / 8 = (48 + 4) / 8 = 52 / 8 = 6.5 → 6 (rounds down) + let result = decimate_weighted(p0, p1, p2, p3); + assert_eq!(result.r, 6); + assert_eq!(result.g, 6); + assert_eq!(result.b, 6); + assert_eq!(result.a, 6); + } + + /// Test [1,3,3,1]/8 with all same values (should be identity). + #[test] + fn test_decimate_weighted_uniform() { + let p = PremulRgba8 { + r: 100, + g: 150, + b: 200, + a: 255, + }; + let result = decimate_weighted(p, p, p, p); + // All same: (100 + 300 + 300 + 100 + 4) / 8 = 804/8 = 100.5 → 100 (rounds down) + assert_eq!(result.r, 100); + assert_eq!(result.g, 150); + assert_eq!(result.b, 200); + assert_eq!(result.a, 255); + } + + /// Test [0.25, 0.75] interpolation. + #[test] + fn test_interpolate_25_75() { + let p0 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let p1 = PremulRgba8 { + r: 100, + g: 100, + b: 100, + a: 100, + }; + + // (0 + 300 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down) + let result = interpolate_25_75(p0, p1); + assert_eq!(result.r, 75); + assert_eq!(result.g, 75); + assert_eq!(result.b, 75); + assert_eq!(result.a, 75); + } + + /// Test [0.75, 0.25] interpolation. + #[test] + fn test_interpolate_75_25() { + let p0 = PremulRgba8 { + r: 100, + g: 100, + b: 100, + a: 100, + }; + let p1 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + + // (300 + 0 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down) + let result = interpolate_75_25(p0, p1); + assert_eq!(result.r, 75); + assert_eq!(result.g, 75); + assert_eq!(result.b, 75); + assert_eq!(result.a, 75); + } + + /// Test interpolation symmetry. + #[test] + fn test_interpolation_symmetry() { + let p0 = PremulRgba8 { + r: 50, + g: 100, + b: 150, + a: 200, + }; + let p1 = PremulRgba8 { + r: 200, + g: 150, + b: 100, + a: 50, + }; + + let r1 = interpolate_25_75(p0, p1); + let r2 = interpolate_75_25(p1, p0); + + // Should be symmetric + assert!((r1.r as i32 - r2.r as i32).abs() <= 0); + assert!((r1.g as i32 - r2.g as i32).abs() <= 0); + assert!((r1.b as i32 - r2.b as i32).abs() <= 0); + assert!((r1.a as i32 - r2.a as i32).abs() <= 0); + } + + /// Test that very small image sizes don't panic. + #[test] + fn test_small_image_sizes() { + let mut pixmap = Pixmap::new(1, 1); + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(2.0); + + // Should not panic + let result = std::panic::catch_unwind(move || { + let mut scratch = Pixmap::new(1, 1); + apply_blur( + &mut pixmap, + &mut scratch, + n_decimations, + &kernel[..usize::from(kernel_size)], + EdgeMode::None, + ); + }); + + assert!(result.is_ok()); + } + + /// Test downscale with odd dimensions. + #[test] + fn test_downscale_odd_dimensions() { + let mut pixmap = Pixmap::new(5, 5); + // Fill with white + for y in 0..5 { + for x in 0..5 { + pixmap.set_pixel( + x, + y, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + } + } + + let (new_width, new_height) = downscale(&mut pixmap, 5, 5, EdgeMode::Duplicate); + // 5 / 2 = 2.5 → ceil = 3 + assert_eq!(new_width, 3); + assert_eq!(new_height, 3); + } + + /// Test upscale dimensions. + #[test] + fn test_upscale_dimensions() { + let mut pixmap = Pixmap::new(6, 6); + let (new_width, new_height) = upscale(&mut pixmap, 3, 3, EdgeMode::Duplicate); + // 3 * 2 = 6 + assert_eq!(new_width, 6); + assert_eq!(new_height, 6); + } + + /// Test that horizontal convolution preserves uniform colors. + #[test] + fn test_convolve_x_uniform() { + let mut src = Pixmap::new(5, 3); + let mut dst = Pixmap::new(5, 3); + // Fill with uniform gray + src.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (kernel, kernel_size) = compute_gaussian_kernel(1.0); + convolve_x( + &src, + &mut dst, + 5, + 3, + &kernel[..usize::from(kernel_size)], + kernel_size / 2, + EdgeMode::Duplicate, + ); + + // Uniform input should produce uniform output + for y in 0..3 { + for x in 0..5 { + let pixel = dst.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + } + ); + } + } + } + + /// Test that vertical convolution preserves uniform colors. + #[test] + fn test_convolve_y_uniform() { + let mut src = Pixmap::new(3, 5); + let mut dst = Pixmap::new(3, 5); + // Fill with uniform gray + src.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (kernel, kernel_size) = compute_gaussian_kernel(1.0); + convolve_y( + &src, + &mut dst, + 3, + 5, + &kernel[..usize::from(kernel_size)], + kernel_size / 2, + EdgeMode::Duplicate, + ); + + // Uniform input should produce uniform output + for y in 0..5 { + for x in 0..3 { + let pixel = dst.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + } + ); + } + } + } + + /// Test that convolution with identity kernel is a no-op. + #[test] + fn test_convolve_identity_kernel() { + let mut src = Pixmap::new(3, 3); + let mut dst = Pixmap::new(3, 3); + src.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 100, + b: 50, + a: 200, + }, + ); + + let kernel = [1.0]; // Identity kernel + convolve_x(&src, &mut dst, 3, 3, &kernel, 0, EdgeMode::None); + + // Should be unchanged + assert_eq!(dst.sample(1, 1), src.sample(1, 1)); + } + + /// Test that large sigma values are clamped to `MAX_KERNEL_SIZE`. + #[test] + fn test_large_sigma_clamped_to_max() { + let (kernel, kernel_size) = compute_gaussian_kernel(100.0); + // For σ=100, radius = ceil(300) = 300, size would be 601 + // But it should be clamped to MAX_KERNEL_SIZE + assert_eq!(usize::from(kernel_size), MAX_KERNEL_SIZE); + + // The clamped kernel should still sum to 1.0 (normalized) + let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + /// Test that decimation prevents kernel from exceeding `MAX_KERNEL_SIZE`. + #[test] + fn test_decimation_reduces_kernel_size() { + let (n_decimations, _kernel, kernel_size) = plan_decimated_blur(100.0); + assert_eq!(kernel_size, 9); + assert_eq!(n_decimations, 6, "Large sigma should trigger decimation"); + } + + /// Test sampling behavior at exact boundaries for each edge mode. + #[test] + fn test_sample_x_at_boundaries() { + let mut pixmap = Pixmap::new(3, 1); + pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 10, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 1, + 0, + PremulRgba8 { + r: 20, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 2, + 0, + PremulRgba8 { + r: 30, + g: 0, + b: 0, + a: 255, + }, + ); + + // Test left boundary with Duplicate + let mut p = sample_x(&pixmap, -1, 0, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 10); // Should clamp to first pixel + + // Test right boundary with Duplicate + p = sample_x(&pixmap, 3, 0, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 30); // Should clamp to last pixel + + // Test with None mode (should return transparent black) + p = sample_x(&pixmap, -1, 0, 3, EdgeMode::None); + assert_eq!(p.a, 0); + } + + /// Test sampling behavior at exact boundaries for vertical sampling. + #[test] + fn test_sample_y_at_boundaries() { + let mut pixmap = Pixmap::new(1, 3); + pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 10, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 0, + 1, + PremulRgba8 { + r: 20, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 0, + 2, + PremulRgba8 { + r: 30, + g: 0, + b: 0, + a: 255, + }, + ); + + // Test top boundary with Duplicate + let mut p = sample_y(&pixmap, 0, -1, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 10); // Should clamp to first pixel + + // Test bottom boundary with Duplicate + p = sample_y(&pixmap, 0, 3, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 30); // Should clamp to last pixel + + // Test with None mode (should return transparent black) + p = sample_y(&pixmap, 0, -1, 3, EdgeMode::None); + assert_eq!(p.a, 0); + } + + /// Test that kernel normalization is precise for various sigma values. + #[test] + fn test_kernel_normalization_precision() { + for sigma in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] { + let (kernel, kernel_size) = compute_gaussian_kernel(sigma); + let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum(); + assert!( + (sum - 1.0).abs() < 1e-6, + "Kernel for σ={} not normalized: sum={}", + sigma, + sum + ); + } + } + + /// Test that downscale → upscale preserves dimensions. + #[test] + fn test_downscale_upscale_roundtrip() { + let mut pixmap = Pixmap::new(8, 8); + // Fill with a pattern + pixmap.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (w1, h1) = downscale(&mut pixmap, 8, 8, EdgeMode::Duplicate); + assert_eq!(w1, 4); + assert_eq!(h1, 4); + + let (w2, h2) = upscale(&mut pixmap, w1, h1, EdgeMode::Duplicate); + assert_eq!(w2, 8); + assert_eq!(h2, 8); + } + + fn pixmap_from_red(width: u16, height: u16, values: &[&[u8]]) -> Pixmap { + let mut pixmap = Pixmap::new(width, height); + for (y, row) in values.iter().enumerate() { + for (x, &r) in row.iter().enumerate() { + pixmap.set_pixel( + x as u16, + y as u16, + PremulRgba8 { + r, + g: 0, + b: 0, + a: 255, + }, + ); + } + } + pixmap + } + + fn assert_red_values(pixmap: &Pixmap, expected: [[u8; W]; H]) { + for (y, row) in expected.iter().enumerate() { + for (x, &want) in row.iter().enumerate() { + let got = pixmap.sample(x as u16, y as u16).r; + + assert_eq!( + got, want, + "red mismatch at ({x}, {y}): got {got}, expected {want}" + ); + } + } + } + + #[test] + fn test_downscale_x_non_uniform() { + // (0,0)=0 (1,0)=40 (2,0)=80 (3,0)=120 + // (0,1)=20 (1,1)=60 (2,1)=100 (3,1)=140 + let mut pixmap = pixmap_from_red(4, 2, &[&[0, 40, 80, 120], &[20, 60, 100, 140]]); + + let dst_width = 4_u16.div_ceil(2); + downscale_x(&mut pixmap, 4, 2, dst_width, EdgeMode::Duplicate); + + // Row 0: p[-1]=0,p[0]=0,p[1]=40,p[2]=80 → (0+0*3+40*3+80+4)>>3 = 25 + // p[1]=40,p[2]=80,p[3]=120,p[4]=120 → (40+80*3+120*3+120+4)>>3 = 95 + // Row 1: p[-1]=20,p[0]=20,p[1]=60,p[2]=100 → (20+20*3+60*3+100+4)>>3 = 45 + // p[1]=60,p[2]=100,p[3]=140,p[4]=140 → (60+100*3+140*3+140+4)>>3 = 115 + assert_red_values(&pixmap, [[25, 95], [45, 115]]); + } + + #[test] + fn test_downscale_y_non_uniform() { + // Use a 2x4 image (output of a prior downscale_x). + // (0,0)=25 (1,0)=95 + // (0,1)=45 (1,1)=115 + // (0,2)=35 (1,2)=105 + // (0,3)=55 (1,3)=125 + let mut pixmap = pixmap_from_red(2, 4, &[&[25, 95], &[45, 115], &[35, 105], &[55, 125]]); + + let dst_height = 4_u16.div_ceil(2); + downscale_y(&mut pixmap, 2, 4, dst_height, EdgeMode::Duplicate); + + // Col 0: p[-1]=25,p[0]=25,p[1]=45,p[2]=35 → (25+25*3+45*3+35+4)>>3 = 34 + // p[1]=45,p[2]=35,p[3]=55,p[4]=55 → (45+35*3+55*3+55+4)>>3 = 46 + // Col 1: p[-1]=95,p[0]=95,p[1]=115,p[2]=105 → (95+95*3+115*3+105+4)>>3 = 104 + // p[1]=115,p[2]=105,p[3]=125,p[4]=125 → (115+105*3+125*3+125+4)>>3 = 116 + assert_red_values(&pixmap, [[34, 104], [46, 116]]); + } + + #[test] + fn test_upscale_x_non_uniform() { + let mut pixmap = pixmap_from_red(4, 2, &[&[34, 104], &[46, 116]]); + + upscale_x(&mut pixmap, 2, 2, EdgeMode::Duplicate); + + // Row 0 [34, 104]: + // x=0: interp25_75(34,34)=34, interp75_25(34,104)=(34*3+104+2)>>2 = 52 + // x=1: interp25_75(34,104)=(34+104*3+2)>>2=87, interp75_25(104,104)=104 + // Row 1 [46, 116]: + // x=0: 46, interp75_25(46,116)=(46*3+116+2)>>2=64 + // x=1: interp25_75(46,116)=(46+116*3+2)>>2=99, 116 + assert_red_values(&pixmap, [[34, 52, 87, 104], [46, 64, 99, 116]]); + } + + #[test] + fn test_upscale_y_non_uniform() { + let mut pixmap = pixmap_from_red(4, 4, &[&[34, 52, 87, 104], &[46, 64, 99, 116]]); + + upscale_y(&mut pixmap, 4, 2, EdgeMode::Duplicate); + + #[rustfmt::skip] + assert_red_values(&pixmap, [ + [ 34, 52, 87, 104], + [ 37, 55, 90, 107], + [ 43, 61, 96, 113], + [ 46, 64, 99, 116], + ]); + } +} diff --git a/vendor/vello_cpu/src/filter/mod.rs b/vendor/vello_cpu/src/filter/mod.rs new file mode 100644 index 0000000000..c0d56ccc13 --- /dev/null +++ b/vendor/vello_cpu/src/filter/mod.rs @@ -0,0 +1,120 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Filter effects implementation for `vello_cpu`. +//! +//! This module provides CPU-based implementations of SVG filter effects, +//! supporting both low-precision (u8) and high-precision (f32) rendering paths. +//! Filters are applied to layers through the layer manager, which handles +//! intermediate storage. + +mod drop_shadow; +mod flood; +mod gaussian_blur; +mod offset; +mod shift; + +use crate::layer_manager::LayerManager; +use vello_common::filter::PreparedFilter; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::pixmap::Pixmap; + +/// Trait for filter effects that can be applied to layers. +/// +/// Each filter implements this trait with both u8 and f32 variants +/// to support different rendering backends and precision requirements. +/// The low-precision path (`execute_lowp`) uses 8-bit color channels for +/// better performance, while the high-precision path (`execute_highp`) +/// uses 32-bit floating-point for higher quality at the cost of speed. +pub(crate) trait FilterEffect { + /// Apply the low-precision (u8) version of the filter. + /// + /// # Arguments + /// * `pixmap` - The target pixmap containing rendering metadata + /// * `layer_manager` - Manager for allocating and accessing intermediate layers + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager); + + /// Apply the high-precision (f32) version of the filter. + /// + /// # Arguments + /// * `pixmap` - The target pixmap containing rendering metadata + /// * `layer_manager` - Manager for allocating and accessing intermediate layers + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager); +} + +/// Apply the low-precision (u8) version of a filter effect to a layer. +/// +/// This function dispatches filter primitives from a filter graph to their +/// corresponding CPU implementations using 8-bit color channels. +/// +/// # Arguments +/// * `filter` - The filter containing the graph of primitives to apply +/// * `pixmap` - The target pixmap containing rendering metadata +/// * `layer_manager` - Manager for allocating and accessing intermediate layers +/// * `transform` - The transformation matrix to extract scale from for filter parameters +/// +/// # Limitations +/// Currently only supports filter graphs with a single primitive. +/// Multi-primitive filter graphs are not yet implemented. +pub(crate) fn filter_lowp( + filter: &Filter, + pixmap: &mut Pixmap, + layer_manager: &mut LayerManager, + transform: Affine, +) { + let prepared_filter = PreparedFilter::new(filter, &transform); + + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::Offset(offset) => { + offset.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_lowp(pixmap, layer_manager); + } + } +} + +/// Apply the high-precision (f32) version of a filter effect to a layer. +/// +/// This function dispatches filter primitives from a filter graph to their +/// corresponding CPU implementations using 32-bit floating-point color channels. +/// +/// # Arguments +/// * `filter` - The filter containing the graph of primitives to apply +/// * `pixmap` - The target pixmap containing rendering metadata +/// * `layer_manager` - Manager for allocating and accessing intermediate layers +/// * `transform` - The transformation matrix to extract scale from for filter parameters +/// +/// # Limitations +/// Currently only supports filter graphs with a single primitive. +/// Multi-primitive filter graphs are not yet implemented. +pub(crate) fn filter_highp( + filter: &Filter, + pixmap: &mut Pixmap, + layer_manager: &mut LayerManager, + transform: Affine, +) { + let prepared_filter = PreparedFilter::new(filter, &transform); + + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_highp(pixmap, layer_manager); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_highp(pixmap, layer_manager); + } + PreparedFilter::Offset(offset) => { + offset.execute_highp(pixmap, layer_manager); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_highp(pixmap, layer_manager); + } + } +} diff --git a/vendor/vello_cpu/src/filter/offset.rs b/vendor/vello_cpu/src/filter/offset.rs new file mode 100644 index 0000000000..e91120d59a --- /dev/null +++ b/vendor/vello_cpu/src/filter/offset.rs @@ -0,0 +1,44 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `feOffset` filter primitive implementation. + +use vello_common::filter::offset::Offset; +use vello_common::pixmap::Pixmap; + +use super::FilterEffect; +use super::shift::offset_pixels; +use crate::layer_manager::LayerManager; + +impl FilterEffect for Offset { + fn execute_lowp(&self, pixmap: &mut Pixmap, _: &mut LayerManager) { + offset_pixels(pixmap, self.dx, self.dy); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, _: &mut LayerManager) { + offset_pixels(pixmap, self.dx, self.dy); + } +} + +#[cfg(test)] +mod tests { + use super::Offset; + use crate::filter::FilterEffect; + use crate::layer_manager::LayerManager; + use vello_common::peniko::color::PremulRgba8; + use vello_common::pixmap::Pixmap; + + #[test] + fn offset_moves_pixels_and_clears_uncovered_area() { + let mut layer_manager = LayerManager::new(); + let mut pixmap = Pixmap::new(4, 3); + pixmap.set_pixel(1, 1, PremulRgba8::from_u32(0xff_00_00_ff)); // premul red, opaque + + Offset::new(2.0, -1.0).execute_lowp(&mut pixmap, &mut layer_manager); + + // Original pixel (1,1) moved to (3,0). + assert_eq!(pixmap.sample(3, 0), PremulRgba8::from_u32(0xff_00_00_ff)); + // Original location cleared. + assert_eq!(pixmap.sample(1, 1), PremulRgba8::from_u32(0)); + } +} diff --git a/vendor/vello_cpu/src/filter/shift.rs b/vendor/vello_cpu/src/filter/shift.rs new file mode 100644 index 0000000000..82660794f9 --- /dev/null +++ b/vendor/vello_cpu/src/filter/shift.rs @@ -0,0 +1,253 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shared helpers for pixel-space filter operations. + +use vello_common::color::palette::css::TRANSPARENT; +#[cfg(not(feature = "std"))] +use vello_common::kurbo::common::FloatFuncs as _; +use vello_common::peniko::color::PremulRgba8; +use vello_common::pixmap::Pixmap; + +/// Shift all pixels in a pixmap by the given offset. +/// +/// This implements the offset operation in-place by copying pixels to their new positions. +/// The iteration order is carefully chosen based on shift direction to avoid overwriting +/// source pixels before they're read. Areas that become exposed (due to the shift) are +/// filled with transparent black. Pixels that would move outside the bounds are discarded. +pub(crate) fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) { + let dx_pixels = dx.round() as i32; + let dy_pixels = dy.round() as i32; + + // Early return if no offset + if dx_pixels == 0 && dy_pixels == 0 { + return; + } + + let width = pixmap.width(); + let height = pixmap.height(); + let transparent = TRANSPARENT.premultiply().to_rgba8(); + + // Process pixels in the correct order to avoid overwriting source data. + // Key insight: iterate away from the direction of movement. + // This allows us to move pixels in-place without a temporary buffer. + // + // Use match to eliminate Box allocation overhead and enable + // better compiler optimization through static dispatch. + match (dx_pixels >= 0, dy_pixels >= 0) { + (true, true) => { + // Shift right+down: iterate bottom-to-top, right-to-left + for y in (0..height).rev() { + for x in (0..width).rev() { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (true, false) => { + // Shift right+up: iterate top-to-bottom, right-to-left + for y in 0..height { + for x in (0..width).rev() { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (false, true) => { + // Shift left+down: iterate bottom-to-top, left-to-right + for y in (0..height).rev() { + for x in 0..width { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (false, false) => { + // Shift left+up: iterate top-to-bottom, left-to-right + for y in 0..height { + for x in 0..width { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + } +} + +/// Process a single pixel during offset operation. +/// +/// This moves the pixel to its new position (if in bounds) and clears the source +/// position if it's in the exposed region. +#[inline(always)] +fn process_offset_pixel( + pixmap: &mut Pixmap, + x: u16, + y: u16, + dx_pixels: i32, + dy_pixels: i32, + width: u16, + height: u16, + transparent: PremulRgba8, +) { + let new_x = x as i32 + dx_pixels; + let new_y = y as i32 + dy_pixels; + + if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 { + let pixel = pixmap.sample(x, y); + pixmap.set_pixel(new_x as u16, new_y as u16, pixel); + } + + // Clear the source pixel if it's in the exposed region + let should_clear = (dx_pixels > 0 && x < dx_pixels as u16) + || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16) + || (dy_pixels > 0 && y < dy_pixels as u16) + || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16); + + if should_clear { + pixmap.set_pixel(x, y, transparent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test `offset_pixels` with positive offset (right and down). + #[test] + fn test_offset_pixels_positive() { + let mut pixmap = Pixmap::new(4, 4); + // Set center pixel to white + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + offset_pixels(&mut pixmap, 1.0, 1.0); + + // White pixel should have moved from (1,1) to (2,2) + let moved = pixmap.sample(2, 2); + assert_eq!(moved.a, 255); + + // Original position should be cleared (in exposed region) + let cleared = pixmap.sample(1, 1); + assert_eq!(cleared.a, 0); + } + + /// Test `offset_pixels` with negative offset (left and up). + #[test] + fn test_offset_pixels_negative() { + let mut pixmap = Pixmap::new(4, 4); + // Set pixel at (2,2) to white + pixmap.set_pixel( + 2, + 2, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + offset_pixels(&mut pixmap, -1.0, -1.0); + + // White pixel should have moved from (2,2) to (1,1) + let moved = pixmap.sample(1, 1); + assert_eq!(moved.a, 255); + + // Original position should be cleared (in exposed region) + let cleared = pixmap.sample(2, 2); + assert_eq!(cleared.a, 0); + } + + /// Test `offset_pixels` with fractional offset (should round). + #[test] + fn test_offset_pixels_fractional() { + let mut pixmap = Pixmap::new(4, 4); + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + // 0.6 rounds to 1, -0.4 rounds to 0 + offset_pixels(&mut pixmap, 0.6, -0.4); + + // Should move by (1, 0): from (1,1) to (2,1) + let moved = pixmap.sample(2, 1); + assert_eq!(moved.a, 255); + } + + /// Test `offset_pixels` with out-of-bounds offset (should clip). + #[test] + fn test_offset_pixels_out_of_bounds() { + let mut pixmap = Pixmap::new(4, 4); + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + // Large offset that moves pixel outside bounds + offset_pixels(&mut pixmap, 10.0, 10.0); + + // Pixel moves outside, so (1,1) should be cleared + let cleared = pixmap.sample(1, 1); + assert_eq!(cleared.a, 0); + + // All pixels should be cleared (entire image is exposed region) + for y in 0..4 { + for x in 0..4 { + assert_eq!(pixmap.sample(x, y).a, 0); + } + } + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/linear.rs b/vendor/vello_cpu/src/fine/common/gradient/linear.rs new file mode 100644 index 0000000000..c79d14b72f --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/linear.rs @@ -0,0 +1,28 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use core::marker::PhantomData; +use vello_common::encode::LinearKind; +use vello_common::fearless_simd::{Simd, f32x8}; + +#[derive(Debug)] +pub(crate) struct SimdLinearKind { + // For consistency with the other gradient types. + phantom_data: PhantomData, +} + +impl SimdLinearKind { + pub(crate) fn new(_: S, _: LinearKind) -> Self { + Self { + phantom_data: PhantomData, + } + } +} + +impl SimdGradientKind for SimdLinearKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, _: f32x8) -> f32x8 { + x_pos + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/mod.rs b/vendor/vello_cpu/src/fine/common/gradient/mod.rs new file mode 100644 index 0000000000..60cdbf8518 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/mod.rs @@ -0,0 +1,246 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::{NumericVec, PosExt}; +use crate::kurbo::Point; +use crate::peniko; +use core::slice::ChunksExact; +use vello_common::encode::{EncodedGradient, GradientLut}; +use vello_common::fearless_simd::*; + +pub(crate) mod linear; +pub(crate) mod radial; +pub(crate) mod sweep; + +const GRADIENT_INVALID_POS: u32 = u32::MAX; + +pub(crate) fn calculate_t_vals>( + simd: S, + kind: U, + buf: &mut [f32], + gradient: &EncodedGradient, + start_x: f64, + start_y: f64, +) { + simd.vectorize( + #[inline(always)] + || { + let mut cur_pos = gradient.transform * Point::new(start_x, start_y); + let x_advances = (gradient.x_advance.x as f32, gradient.x_advance.y as f32); + let y_advances = (gradient.y_advance.x as f32, gradient.y_advance.y as f32); + + for buf_part in buf.chunks_exact_mut(8) { + let x_pos = f32x8::splat_pos(simd, cur_pos.x as f32, x_advances.0, y_advances.0); + let y_pos = f32x8::splat_pos(simd, cur_pos.y as f32, x_advances.1, y_advances.1); + let pos = kind.cur_pos(x_pos, y_pos); + pos.store_slice(buf_part); + + cur_pos += 2.0 * gradient.x_advance; + } + }, + ); +} + +#[derive(Debug, Clone)] +pub(crate) struct GradientPainter<'a, S: Simd> { + gradient: &'a EncodedGradient, + lut: &'a GradientLut, + t_vals: ChunksExact<'a, f32>, + has_undefined: bool, + scale_factor: f32x8, + simd: S, +} + +impl<'a, S: Simd> GradientPainter<'a, S> { + pub(crate) fn new(simd: S, gradient: &'a EncodedGradient, t_vals: &'a [f32]) -> Self { + let lut = gradient.f32_lut(simd); + let scale_factor: f32x8 = f32x8::splat(simd, lut.scale_factor()); + + Self { + gradient, + scale_factor, + lut, + t_vals: t_vals.chunks_exact(8), + has_undefined: gradient.has_undefined, + simd, + } + } +} + +impl Iterator for GradientPainter<'_, S> { + type Item = u32x8; + + #[inline(always)] + fn next(&mut self) -> Option { + let extend = self.gradient.extend; + let pos = f32x8::from_slice(self.simd, self.t_vals.next()?); + let t_vals = apply_extend(pos, extend); + let valid = pos.simd_eq(pos); + let indices = (t_vals * self.scale_factor).to_int::>(); + // In case we had any NaN's, set the index to an explicit invalid sentinel. There + // probably is architecture-specific behavior how NaN will be converted to + // f32, so we apply our own handling of that. + let indices = self.simd.select_u32x8( + valid, + indices, + u32x8::splat(self.simd, GRADIENT_INVALID_POS), + ); + + Some(indices) + } +} + +impl crate::fine::Painter for GradientPainter<'_, S> { + fn paint_u8(&mut self, buf: &mut [u8]) { + let max_index = self.lut.width() as u32 - 1; + let mut masked_iter = self.has_undefined.then_some(self.clone()); + + self.simd.vectorize( + #[inline(always)] + || { + let max_index = u32x8::splat(self.simd, max_index); + + for chunk in buf.chunks_exact_mut(32) { + let indices = self.next().unwrap(); + let clamped_indices = indices.min(max_index); + + let rgbas_1: [f32x4; 4] = core::array::from_fn(|i| { + let idx = clamped_indices[i] as usize; + f32x4::from_slice(self.simd, &self.lut.get(idx)) + }); + let rgbas_1 = self.simd.combine_f32x8( + self.simd.combine_f32x4(rgbas_1[0], rgbas_1[1]), + self.simd.combine_f32x4(rgbas_1[2], rgbas_1[3]), + ); + let rgbas_1 = u8x16::from_f32(self.simd, rgbas_1); + rgbas_1.store_slice(&mut chunk[..16]); + + let rgbas_2: [f32x4; 4] = core::array::from_fn(|i| { + let idx = clamped_indices[i + 4] as usize; + f32x4::from_slice(self.simd, &self.lut.get(idx)) + }); + let rgbas_2 = self.simd.combine_f32x8( + self.simd.combine_f32x4(rgbas_2[0], rgbas_2[1]), + self.simd.combine_f32x4(rgbas_2[2], rgbas_2[3]), + ); + let rgbas_2 = u8x16::from_f32(self.simd, rgbas_2); + rgbas_2.store_slice(&mut chunk[16..]); + } + }, + ); + + // Mask any potential NaN positions. + if let Some(mut masked_iter) = masked_iter.take() { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(32) { + let indices = masked_iter.next().unwrap(); + let invalid = + indices.simd_eq(u32x8::splat(self.simd, GRADIENT_INVALID_POS)); + let (invalid_1, invalid_2) = self.simd.split_mask32x8(invalid); + + let loaded_1 = self + .simd + .reinterpret_u32_u8x16(u8x16::from_slice(self.simd, &chunk[..16])); + let masked_1 = + self.simd + .select_u32x4(invalid_1, u32x4::splat(self.simd, 0), loaded_1); + self.simd + .reinterpret_u8_u32x4(masked_1) + .store_slice(&mut chunk[..16]); + + let loaded_2 = self + .simd + .reinterpret_u32_u8x16(u8x16::from_slice(self.simd, &chunk[16..])); + let masked_2 = + self.simd + .select_u32x4(invalid_2, u32x4::splat(self.simd, 0), loaded_2); + self.simd + .reinterpret_u8_u32x4(masked_2) + .store_slice(&mut chunk[16..]); + } + }, + ); + } + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + let mut masked_iter = self.has_undefined.then_some(self.clone()); + + self.simd.vectorize( + #[inline(always)] + || { + let max_index = self.lut.width() as u32 - 1; + let max_index = u32x8::splat(self.simd, max_index); + + for chunk in buf.chunks_exact_mut(32) { + let indices = self.next().unwrap(); + let clamped_indices = indices.min(max_index); + chunk[0..4].copy_from_slice(&self.lut.get(clamped_indices[0] as usize)); + chunk[4..8].copy_from_slice(&self.lut.get(clamped_indices[1] as usize)); + chunk[8..12].copy_from_slice(&self.lut.get(clamped_indices[2] as usize)); + chunk[12..16].copy_from_slice(&self.lut.get(clamped_indices[3] as usize)); + chunk[16..20].copy_from_slice(&self.lut.get(clamped_indices[4] as usize)); + chunk[20..24].copy_from_slice(&self.lut.get(clamped_indices[5] as usize)); + chunk[24..28].copy_from_slice(&self.lut.get(clamped_indices[6] as usize)); + chunk[28..32].copy_from_slice(&self.lut.get(clamped_indices[7] as usize)); + } + }, + ); + + // Mask any potential NaN positions. + if let Some(mut masked_iter) = masked_iter.take() { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(32) { + let indices = masked_iter.next().unwrap(); + let (indices_1, indices_2) = self.simd.split_u32x8(indices); + let invalid_1 = invalid_f32_mask(self.simd, indices_1); + let loaded_1 = f32x16::from_slice(self.simd, &chunk[..16]); + let masked_1 = self.simd.select_f32x16( + invalid_1, + f32x16::splat(self.simd, 0.0), + loaded_1, + ); + masked_1.store_slice(&mut chunk[..16]); + + let invalid_2 = invalid_f32_mask(self.simd, indices_2); + let loaded_2 = f32x16::from_slice(self.simd, &chunk[16..]); + let masked_2 = self.simd.select_f32x16( + invalid_2, + f32x16::splat(self.simd, 0.0), + loaded_2, + ); + masked_2.store_slice(&mut chunk[16..]); + } + }, + ); + } + } +} + +#[inline(always)] +fn invalid_f32_mask(simd: S, indices: u32x4) -> mask32x16 { + let indices = indices.zip_low(indices).combine(indices.zip_high(indices)); + let indices = indices.zip_low(indices).combine(indices.zip_high(indices)); + indices.simd_eq(u32x16::splat(simd, GRADIENT_INVALID_POS)) +} + +#[inline(always)] +pub(crate) fn apply_extend(val: f32x8, extend: peniko::Extend) -> f32x8 { + match extend { + peniko::Extend::Pad => val.max(0.0).min(1.0), + peniko::Extend::Repeat => (val - val.floor()).fract(), + // See + peniko::Extend::Reflect => ((val - 1.0) - 2.0 * ((val - 1.0) * 0.5).floor() - 1.0) + .abs() + .max(0.0) + .min(1.0), + } +} + +pub(crate) trait SimdGradientKind { + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8; +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/radial.rs b/vendor/vello_cpu/src/fine/common/gradient/radial.rs new file mode 100644 index 0000000000..78ce5fb648 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/radial.rs @@ -0,0 +1,107 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use vello_common::encode::{FocalData, RadialKind}; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8}; + +pub(crate) enum SimdRadialKindInner { + Radial { + bias: f32x8, + scale: f32x8, + }, + Strip { + scaled_r0_squared: f32x8, + }, + Focal { + focal_data: FocalData, + fp0: f32x8, + fp1: f32x8, + }, +} + +pub(crate) struct SimdRadialKind { + inner: SimdRadialKindInner, +} + +impl SimdRadialKind { + pub(crate) fn new(simd: S, kind: &RadialKind) -> Self { + let inner = match kind { + RadialKind::Radial { bias, scale } => SimdRadialKindInner::Radial { + bias: f32x8::splat(simd, *bias), + scale: f32x8::splat(simd, *scale), + }, + RadialKind::Strip { scaled_r0_squared } => SimdRadialKindInner::Strip { + scaled_r0_squared: f32x8::splat(simd, *scaled_r0_squared), + }, + RadialKind::Focal { + focal_data, + fp0, + fp1, + } => SimdRadialKindInner::Focal { + fp0: f32x8::splat(simd, *fp0), + fp1: f32x8::splat(simd, *fp1), + focal_data: *focal_data, + }, + }; + + Self { inner } + } +} + +impl SimdGradientKind for SimdRadialKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8 { + let simd = x_pos.simd; + + match &self.inner { + SimdRadialKindInner::Radial { bias, scale } => { + let radius = (x_pos * x_pos + y_pos * y_pos).sqrt(); + + *bias + radius * *scale + } + SimdRadialKindInner::Strip { scaled_r0_squared } => { + let p1 = *scaled_r0_squared - y_pos * y_pos; + + let mask = simd.simd_lt_f32x8(p1, f32x8::splat(simd, 0.0)); + simd.select_f32x8(mask, f32x8::splat(simd, f32::NAN), x_pos + p1.sqrt()) + } + SimdRadialKindInner::Focal { + focal_data, + fp0, + fp1, + } => { + let mut t = if focal_data.is_focal_on_circle() { + x_pos + y_pos * y_pos / x_pos + } else if focal_data.is_well_behaved() { + (x_pos * x_pos + y_pos * y_pos).sqrt() - x_pos * *fp0 + } else if focal_data.is_swapped() || (1.0 - focal_data.f_focal_x < 0.0) { + f32x8::splat(simd, -1.0) * (x_pos * x_pos - y_pos * y_pos).sqrt() - x_pos * *fp0 + } else { + (x_pos * x_pos - y_pos * y_pos).sqrt() - x_pos * *fp0 + }; + + if !focal_data.is_well_behaved() { + // Radii < 0 should be masked out, too. + let is_degenerate = simd.simd_le_f32x8(t, f32x8::splat(simd, 0.0)); + + t = simd.select_f32x8(is_degenerate, f32x8::splat(simd, f32::NAN), t); + } + + if 1.0 - focal_data.f_focal_x < 0.0 { + t = f32x8::splat(simd, -1.0) * t; + } + + if !focal_data.is_natively_focal() { + t += *fp1; + } + + if focal_data.is_swapped() { + t = f32x8::splat(simd, 1.0) - t; + } + + t + } + } + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/sweep.rs b/vendor/vello_cpu/src/fine/common/gradient/sweep.rs new file mode 100644 index 0000000000..02a0a401ff --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/sweep.rs @@ -0,0 +1,61 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use core::f32::consts::PI; +use vello_common::encode::SweepKind; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8}; + +#[derive(Debug)] +pub(crate) struct SimdSweepKind { + start_angle: f32x8, + inv_angle_delta: f32x8, + simd: S, +} + +impl SimdSweepKind { + pub(crate) fn new(simd: S, kind: &SweepKind) -> Self { + Self { + start_angle: f32x8::splat(simd, kind.start_angle), + inv_angle_delta: f32x8::splat(simd, kind.inv_angle_delta), + simd, + } + } +} + +impl SimdGradientKind for SimdSweepKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8 { + let angle = x_y_to_unit_angle(self.simd, x_pos, y_pos) * f32x8::splat(self.simd, 2.0 * PI); + + (angle - self.start_angle) * self.inv_angle_delta + } +} + +#[inline(always)] +fn x_y_to_unit_angle(simd: S, x: f32x8, y: f32x8) -> f32x8 { + let c0 = f32x8::splat(simd, 0.0); + let c1 = f32x8::splat(simd, 1.0); + let c2 = f32x8::splat(simd, 1.0 / 4.0); + let c3 = f32x8::splat(simd, 1.0 / 2.0); + + let x_abs = x.abs(); + let y_abs = y.abs(); + + let slope = x_abs.min(y_abs) / x_abs.max(y_abs); + let s = slope * slope; + + let a = f32x8::splat(simd, -7.054_738_2e-3).mul_add(s, f32x8::splat(simd, 2.476_102e-2)); + let b = a.mul_add(s, f32x8::splat(simd, -5.185_397e-2)); + let c = b.mul_add(s, f32x8::splat(simd, 0.159_121_17)); + + let mut phi = slope * c; + + phi = simd.select_f32x8(simd.simd_lt_f32x8(x_abs, y_abs), c2 - phi, phi); + phi = simd.select_f32x8(simd.simd_lt_f32x8(x, c0), c3 - phi, phi); + phi = simd.select_f32x8(simd.simd_lt_f32x8(y, c0), c1 - phi, phi); + // Clears all NaNs, using the property that NaN != NaN. + phi = simd.select_f32x8(phi.simd_eq(phi), phi, c0); + + phi +} diff --git a/vendor/vello_cpu/src/fine/common/image.rs b/vendor/vello_cpu/src/fine/common/image.rs new file mode 100644 index 0000000000..dad257b071 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/image.rs @@ -0,0 +1,568 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::macros::{f32x16_painter, u8x16_painter}; +use crate::fine::{PosExt, Splat4thExt, u8_to_f32}; +use crate::kurbo::Point; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Bytes, Simd, SimdBase, SimdFloat, f32x4, f32x16, u8x16, u32x4}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::element_wise_splat; + +/// A painter for nearest-neighbor images with no skewing. +#[derive(Debug)] +pub(crate) struct PlainNNImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + y_positions: f32x4, + cur_x_pos: f32x4, + advance: f32, + simd: S, +} + +impl<'a, S: Simd> PlainNNImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + let y_positions = extend( + simd, + f32x4::splat_pos( + simd, + data.cur_pos.y as f32, + data.x_advances.1, + data.y_advances.1, + ), + image.sampler.y_extend, + data.height, + data.height_inv, + ); + + let cur_x_pos = f32x4::splat_pos( + simd, + data.cur_pos.x as f32, + data.x_advances.0, + data.y_advances.0, + ); + + Self { + data, + advance: image.x_advance.x as f32, + y_positions, + cur_x_pos, + simd, + } + } +} + +impl Iterator for PlainNNImagePainter<'_, S> { + type Item = u8x16; + + #[inline(always)] + fn next(&mut self) -> Option { + let x_pos = extend( + self.simd, + self.cur_x_pos, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + let samples = sample(self.simd, &self.data, x_pos, self.y_positions); + + self.cur_x_pos += self.advance; + + Some(samples) + } +} + +u8x16_painter!(PlainNNImagePainter<'_, S>); + +/// A painter for nearest-neighbor images with arbitrary transforms. +#[derive(Debug)] +pub(crate) struct NNImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd> NNImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for NNImagePainter<'_, S> { + type Item = u8x16; + + fn next(&mut self) -> Option { + let x_positions = extend( + self.simd, + f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ), + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + let y_positions = extend( + self.simd, + f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ), + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ); + + let samples = sample(self.simd, &self.data, x_positions, y_positions); + + self.data.cur_pos += self.data.image.x_advance; + + Some(samples) + } +} + +u8x16_painter!(NNImagePainter<'_, S>); + +/// A painter for images with bilinear or bicubic filtering. +/// +/// The painter is generic over sampler quality using the const-generic `QUALITY` parameter. +/// +/// - Set `QUALITY` to `1` for bilinear sampling; or +/// - set `QUALITY` to `2` for bicubic sampling. +/// +/// These values for `QUALITY` are the same numeric values as defined by +/// [`crate::peniko::ImageQuality`]. +#[derive(Debug)] +pub(crate) struct FilteredImagePainter<'a, S: Simd, const QUALITY: u8> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd, const QUALITY: u8> FilteredImagePainter<'a, S, QUALITY> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for FilteredImagePainter<'_, S, QUALITY> { + type Item = f32x16; + + fn next(&mut self) -> Option { + let x_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ); + + let y_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ); + + // We have two versions of filtering: `Medium` (bilinear filtering) and + // `High` (bicubic filtering). + + // In bilinear filtering, we sample the pixels of the rectangle that spans the + // locations (-0.5, -0.5) and (0.5, 0.5), and weight them by the fractional + // x/y position using simple linear interpolation in both dimensions. + // In bicubic filtering, we instead span a 4x4 grid around the + // center of the location we are sampling, and sample those points + // using a cubic filter to weight each location's contribution. + + let x_fract = fract_floor(x_positions + 0.5); + let y_fract = fract_floor(y_positions + 0.5); + + let mut interpolated_color = f32x16::splat(self.simd, 0.0); + + let sample = |x_pos: f32x4, y_pos: f32x4| { + u8_to_f32(sample(self.simd, &self.data, x_pos, y_pos)) + }; + + macro_rules! extend_x { + ($idx:expr,$offsets:expr) => { + extend( + self.simd, + x_positions + $offsets[$idx], + self.data.image.sampler.y_extend, + self.data.width, + self.data.width_inv, + ) + }; + } + + macro_rules! extend_y { + ($idx:expr,$offsets:expr) => { + extend( + self.simd, + y_positions + $offsets[$idx], + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ) + }; + } + + match QUALITY { + // medium quality: bilinear + 1 => { + // + let cx = [1.0 - x_fract, x_fract]; + let cy = [1.0 - y_fract, y_fract]; + + // Note that the sum of all cx*cy combinations also yields 1.0 again + // (modulo some floating point number impreciseness), ensuring the + // colors stay in range. + + const OFFSETS: [f32; 2] = [-0.5, 0.5]; + + let x_positions = [extend_x!(0, OFFSETS), extend_x!(1, OFFSETS)]; + + let y_positions = [extend_y!(0, OFFSETS), extend_y!(1, OFFSETS)]; + + // We sample the corners of rectangle that covers our current position. + for x_idx in 0..2 { + let x_positions = x_positions[x_idx]; + + for y_idx in 0..2 { + let y_positions = y_positions[y_idx]; + let color_sample = sample(x_positions, y_positions); + let w = element_wise_splat(self.simd, cx[x_idx] * cy[y_idx]); + + interpolated_color = w.mul_add(color_sample, interpolated_color); + } + } + + interpolated_color *= f32x16::splat(self.simd, 1.0 / 255.0); + } + // high quality: bicubic + 2 => { + // Compare to . + let cx = weights(self.simd, x_fract); + let cy = weights(self.simd, y_fract); + + const OFFSETS: [f32; 4] = [-1.5, -0.5, 0.5, 1.5]; + + let x_positions = [ + extend_x!(0, OFFSETS), + extend_x!(1, OFFSETS), + extend_x!(2, OFFSETS), + extend_x!(3, OFFSETS), + ]; + + let y_positions = [ + extend_y!(0, OFFSETS), + extend_y!(1, OFFSETS), + extend_y!(2, OFFSETS), + extend_y!(3, OFFSETS), + ]; + + // Note in particular that it is guaranteed that, similarly to bilinear filtering, + // the sum of all cx*cy is 1 (modulo some edge cases). + + // We sample the 4x4 grid around the position we are currently looking at. + for x_idx in 0..4 { + let x_positions = x_positions[x_idx]; + for y_idx in 0..4 { + let y_positions = y_positions[y_idx]; + + let color_sample = sample(x_positions, y_positions); + let w = element_wise_splat(self.simd, cx[x_idx] * cy[y_idx]); + + interpolated_color = w.mul_add(color_sample, interpolated_color); + } + } + + interpolated_color *= f32x16::splat(self.simd, 1.0 / 255.0); + + let alphas = interpolated_color.splat_4th(); + + // Due to the nature of the cubic filter, it can happen in certain situations + // that one of the color components ends up with a higher value than the + // alpha component, which isn't permissible because the color is + // premultiplied and would lead to overflows when doing source over + // compositing with u8-based values. Because of this, we need to clamp + // to the alpha value. + interpolated_color = interpolated_color + .min(alphas) + .min(f32x16::splat(self.simd, 1.0)) + .max(f32x16::splat(self.simd, 0.0)); + } + _ => panic!( + "Unknown value for `FilteredImagePainter`'s const-generic `QUALITY` parameter. Expected `1` for bilinear or `2` for bicubic, got: `{QUALITY}`." + ), + } + + self.data.cur_pos += self.data.image.x_advance; + + Some(interpolated_color) + } +} + +// Bilinear +f32x16_painter!(FilteredImagePainter<'_, S, 1>); +// Bicubic +f32x16_painter!(FilteredImagePainter<'_, S, 2>); + +/// Computes the positive fractional part of a value: `val - val.floor()`. +/// +/// Unlike `f32::fract()`, this always returns a value in [0, 1), +/// even for negative inputs. +#[inline(always)] +pub(crate) fn fract_floor(val: f32x4) -> f32x4 { + val - val.floor() +} + +/// Common data used by different image painters +#[derive(Debug)] +pub(crate) struct ImagePainterData<'a, S: Simd> { + pub(crate) cur_pos: Point, + pub(crate) image: &'a EncodedImage, + pub(crate) pixmap: &'a Pixmap, + pub(crate) x_advances: (f32, f32), + pub(crate) y_advances: (f32, f32), + pub(crate) height: f32x4, + pub(crate) height_inv: f32x4, + pub(crate) width: f32x4, + pub(crate) width_inv: f32x4, + pub(crate) width_u32: u32x4, +} + +impl<'a, S: Simd> ImagePainterData<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let width = pixmap.width() as f32; + let height = pixmap.height() as f32; + let start_pos = image.transform * Point::new(start_x, start_y); + + let width_inv = f32x4::splat(simd, 1.0 / width); + let height_inv = f32x4::splat(simd, 1.0 / height); + let width = f32x4::splat(simd, width); + let width_u32 = u32x4::splat(simd, pixmap.width() as u32); + let height = f32x4::splat(simd, height); + + let x_advances = (image.x_advance.x as f32, image.x_advance.y as f32); + let y_advances = (image.y_advance.x as f32, image.y_advance.y as f32); + + Self { + cur_pos: start_pos, + pixmap, + x_advances, + y_advances, + image, + width, + height, + width_u32, + width_inv, + height_inv, + } + } +} + +#[inline(always)] +pub(crate) fn sample( + simd: S, + data: &ImagePainterData<'_, S>, + x_positions: f32x4, + y_positions: f32x4, +) -> u8x16 { + let idx = x_positions.to_int::>() + y_positions.to_int::>() * data.width_u32; + + u32x4::from_slice( + simd, + &[ + data.pixmap.sample_idx(idx[0]).to_u32(), + data.pixmap.sample_idx(idx[1]).to_u32(), + data.pixmap.sample_idx(idx[2]).to_u32(), + data.pixmap.sample_idx(idx[3]).to_u32(), + ], + ) + .to_bytes() +} + +#[inline(always)] +pub(crate) fn extend( + simd: S, + val: f32x4, + extend: crate::peniko::Extend, + max: f32x4, + inv_max: f32x4, +) -> f32x4 { + match extend { + // Note that max should be exclusive, so subtract one to enforce that. + // Since the maximum image dimensions we support is u16::MAX, subtracting 1 in f32 + // is enough to ensure that all numbers are subtracted correctly. + crate::peniko::Extend::Pad => val.min(max - 1.0).max(f32x4::splat(simd, 0.0)), + crate::peniko::Extend::Repeat => { + // floor := (val * inv_max).floor() * max is the nearest multiple of `max` below val. + max.mul_add(-(val * inv_max).floor(), val) + // In certain edge cases, we might still end up with a higher number. + .min(max - 1.0) + } + // + crate::peniko::Extend::Reflect => { + let u = val + - (val * inv_max * f32x4::splat(simd, 0.5)).floor() * f32x4::splat(simd, 2.0) * max; + let s = (u * inv_max).floor(); + let m = u - f32x4::splat(simd, 2.0) * s * (u - max); + + let bias_in_ulps = s.trunc(); + + let m_bits = u32x4::from_bytes(m.to_bytes()); + // This would yield NaN if `m` is 0 and `bias_in_ulps` > 0, but since + // our `max` is always an integer number, u and s must also be an integer number + // and thus `m_bits` must be 0. + // Note that this is a wrapping sub! + let biased_bits = m_bits - bias_in_ulps.to_int::>(); + f32x4::from_bytes(biased_bits.to_bytes()) + // In certain edge cases, we might still end up with a higher number. + .min(max - 1.0) + } + } +} + +/// Calculate the weights for a single fractional value. +fn weights(simd: S, fract: f32x4) -> [f32x4; 4] { + simd.vectorize( + #[inline(always)] + || { + let s = fract.simd; + const MF: [[f32; 4]; 4] = mf_resampler(); + + [ + single_weight( + fract, + f32x4::splat(s, MF[0][0]), + f32x4::splat(s, MF[0][1]), + f32x4::splat(s, MF[0][2]), + f32x4::splat(s, MF[0][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[1][0]), + f32x4::splat(s, MF[1][1]), + f32x4::splat(s, MF[1][2]), + f32x4::splat(s, MF[1][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[2][0]), + f32x4::splat(s, MF[2][1]), + f32x4::splat(s, MF[2][2]), + f32x4::splat(s, MF[2][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[3][0]), + f32x4::splat(s, MF[3][1]), + f32x4::splat(s, MF[3][2]), + f32x4::splat(s, MF[3][3]), + ), + ] + }, + ) +} + +/// Calculate a weight based on the fractional value t and the cubic coefficients. +#[inline(always)] +fn single_weight( + t: f32x4, + a: f32x4, + b: f32x4, + c: f32x4, + d: f32x4, +) -> f32x4 { + t.mul_add(d, c).mul_add(t, b).mul_add(t, a) +} + +/// Mitchell filter with the variables B = 1/3 and C = 1/3. +const fn mf_resampler() -> [[f32; 4]; 4] { + cubic_resampler(1.0 / 3.0, 1.0 / 3.0) +} + +/// Cubic resampling logic is borrowed from Skia. See +/// +/// for some links to understand how this works. In principle, this macro allows us to define a +/// resampler kernel based on two variables B and C which can be between 0 and 1, allowing to +/// change some properties of the cubic interpolation kernel. +/// +/// As mentioned above, cubic resampling consists of sampling the 16 surrounding pixels of the +/// target point and interpolating them with a cubic filter. +/// The generated matrix is 4x4 and represent the coefficients of the cubic function used to +/// calculate weights based on the `x_fract` and `y_fract` of the location we are looking at. +const fn cubic_resampler(b: f32, c: f32) -> [[f32; 4]; 4] { + [ + [ + (1.0 / 6.0) * b, + -(3.0 / 6.0) * b - c, + (3.0 / 6.0) * b + 2.0 * c, + -(1.0 / 6.0) * b - c, + ], + [ + 1.0 - (2.0 / 6.0) * b, + 0.0, + -3.0 + (12.0 / 6.0) * b + c, + 2.0 - (9.0 / 6.0) * b - c, + ], + [ + (1.0 / 6.0) * b, + (3.0 / 6.0) * b + c, + 3.0 - (15.0 / 6.0) * b - 2.0 * c, + -2.0 + (9.0 / 6.0) * b + c, + ], + [0.0, 0.0, -c, (1.0 / 6.0) * b + c], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::fearless_simd::Fallback; + + #[test] + fn extend_overflow() { + let simd = Fallback::new(); + let max = f32x4::splat(simd, 128.0); + let max_inv = 1.0 / max; + + let num = f32x4::splat(simd, 127.00001); + let res = extend(simd, num, crate::peniko::Extend::Repeat, max, max_inv); + + assert!(res[0] <= 127.0); + } +} diff --git a/vendor/vello_cpu/src/fine/common/mod.rs b/vendor/vello_cpu/src/fine/common/mod.rs new file mode 100644 index 0000000000..8b19b8e9d3 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +pub(crate) mod gradient; +pub(crate) mod image; +pub(crate) mod rounded_blurred_rect; diff --git a/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs b/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs new file mode 100644 index 0000000000..dc84f22a00 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs @@ -0,0 +1,249 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Drawing blurred, rounded rectangles. +//! +//! Implementation is adapted from: . + +use crate::fine::{NumericVec, PosExt, ShaderResultF32}; +use crate::kurbo::{Point, Vec2}; +use vello_common::encode::EncodedBlurredRoundedRectangle; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8, u8x16}; + +#[cfg(not(feature = "std"))] +use vello_common::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +pub(crate) struct BlurredRoundedRectFiller { + r: f32x8, + g: f32x8, + b: f32x8, + a: f32x8, + alpha_calculator: AlphaCalculator, +} + +impl BlurredRoundedRectFiller { + pub(crate) fn new( + simd: S, + rect: &EncodedBlurredRoundedRectangle, + start_x: f64, + start_y: f64, + ) -> Self { + let start_pos = rect.transform * Point::new(start_x, start_y); + let color_components = rect.color.as_premul_f32().components; + let r = f32x8::splat(simd, color_components[0]); + let g = f32x8::splat(simd, color_components[1]); + let b = f32x8::splat(simd, color_components[2]); + let a = f32x8::splat(simd, color_components[3]); + let simd_rect = SimdRoundedBlurredRect::new(rect, simd); + let alpha_calculator = + AlphaCalculator::new(start_pos, rect.x_advance, rect.y_advance, simd_rect, simd); + + Self { + alpha_calculator, + r, + g, + b, + a, + } + } +} + +impl Iterator for BlurredRoundedRectFiller { + type Item = ShaderResultF32; + + fn next(&mut self) -> Option { + let next = self.alpha_calculator.next().unwrap(); + let r = self.r * next; + let g = self.g * next; + let b = self.b * next; + let a = self.a * next; + + Some(ShaderResultF32 { r, g, b, a }) + } +} + +impl crate::fine::Painter for BlurredRoundedRectFiller { + fn paint_u8(&mut self, buf: &mut [u8]) { + for chunk in buf.chunks_exact_mut(64) { + let first = self.next().unwrap(); + let simd = first.r.simd; + let second = self.next().unwrap(); + + let r = u8x16::from_f32(simd, simd.combine_f32x8(first.r, second.r)); + let g = u8x16::from_f32(simd, simd.combine_f32x8(first.g, second.g)); + let b = u8x16::from_f32(simd, simd.combine_f32x8(first.b, second.b)); + let a = u8x16::from_f32(simd, simd.combine_f32x8(first.a, second.a)); + + let combined = simd.combine_u8x32(simd.combine_u8x16(r, g), simd.combine_u8x16(b, a)); + + simd.store_interleaved_128_u8x64(combined, (&mut chunk[..]).try_into().unwrap()); + } + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + for chunk in buf.chunks_exact_mut(32) { + let (c1, c2) = self.next().unwrap().get(); + c1.simd + .store_interleaved_128_f32x16(c1, (&mut chunk[..16]).try_into().unwrap()); + c2.simd + .store_interleaved_128_f32x16(c2, (&mut chunk[16..]).try_into().unwrap()); + } + } +} + +#[derive(Debug)] +struct AlphaCalculator { + cur_pos: Point, + x_advance: Vec2, + y_advance: Vec2, + r: SimdRoundedBlurredRect, + simd: S, +} + +impl AlphaCalculator { + fn new( + start_pos: Point, + x_advance: Vec2, + y_advance: Vec2, + r: SimdRoundedBlurredRect, + simd: S, + ) -> Self { + Self { + cur_pos: start_pos, + x_advance, + y_advance, + r, + simd, + } + } +} + +impl Iterator for AlphaCalculator { + type Item = f32x8; + + fn next(&mut self) -> Option { + let i = f32x8::splat_pos( + self.simd, + self.cur_pos.x as f32, + self.x_advance.x as f32, + self.y_advance.x as f32, + ); + let j = f32x8::splat_pos( + self.simd, + self.cur_pos.y as f32, + self.x_advance.y as f32, + self.y_advance.y as f32, + ); + let r = &self.r; + + let y = j - r.v1 * r.height; + // Equivalent to r.r1 + y.abs() - (r.h * r.v1) + let y0 = r.r1 - r.h.mul_sub(r.v1, y.abs()); + let y1 = y0.max(r.v0); + + let x = i - r.v1 * r.width; + // Equivalent to r.r1 + x.abs() - (r.w * r.v1) + let x0 = r.r1 - r.w.mul_sub(r.v1, x.abs()); + let x1 = x0.max(r.v0); + let d_pos = (x1.powf(r.exponent) + y1.powf(r.exponent)).powf(r.recip_exponent); + let d_neg = x0.max(y0).min(r.v0); + let d = d_pos + d_neg - r.r1; + let z = r.scale + * (f32x8::compute_erf7(self.simd, r.std_dev_inv * (r.min_edge + d)) + - f32x8::compute_erf7(self.simd, r.std_dev_inv * d)); + + self.cur_pos += 2.0 * self.x_advance; + + Some(z) + } +} + +#[derive(Debug)] +struct SimdRoundedBlurredRect { + pub exponent: f32, + pub recip_exponent: f32, + pub scale: f32x8, + pub std_dev_inv: f32x8, + pub min_edge: f32x8, + pub w: f32x8, + pub h: f32x8, + pub width: f32x8, + pub height: f32x8, + pub r1: f32x8, + pub v0: f32x8, + pub v1: f32x8, +} + +impl SimdRoundedBlurredRect { + fn new(encoded: &EncodedBlurredRoundedRectangle, s: S) -> Self { + let h = f32x8::splat(s, encoded.h); + let w = f32x8::splat(s, encoded.w); + let width = f32x8::splat(s, encoded.width); + let height = f32x8::splat(s, encoded.height); + let r1 = f32x8::splat(s, encoded.r1); + let exponent = encoded.exponent; + let recip_exponent = encoded.recip_exponent; + let scale = f32x8::splat(s, encoded.scale); + let min_edge = f32x8::splat(s, encoded.min_edge); + let std_dev_inv = f32x8::splat(s, encoded.std_dev_inv); + let v0 = f32x8::splat(s, 0.0); + let v1 = f32x8::splat(s, 0.5); + + Self { + exponent, + recip_exponent, + scale, + std_dev_inv, + min_edge, + w, + v0, + v1, + h, + width, + height, + r1, + } + } +} + +trait FloatExt { + // See https://raphlinus.github.io/audio/2018/09/05/sigmoid.html for a little + // explanation of this approximation to the erf function. + // Doing `inline(always)` seems to reduce performance for some reason. + /// Approximate the erf function. + fn compute_erf7(simd: S, x: Self) -> Self; + fn powf(self, x: f32) -> Self; +} + +impl FloatExt for f32x8 { + fn compute_erf7(simd: S, x: Self) -> Self { + // Clamp `x`, because for large `x` the terms here become `inf`, causing the result to be 0 or + // `NaN`. This clamping doesn't lose any information, because `erf(±10) ≈ 1` well within `f64` + // machine precision, let alone `f32`. + let x = x.max(Self::splat(simd, -10.0)).min(Self::splat(simd, 10.0)); + let x = x * Self::splat(simd, core::f32::consts::FRAC_2_SQRT_PI); + let xx = x * x; + let p1 = Self::splat(simd, 0.0104).mul_add(xx, Self::splat(simd, 0.03395)); + let p2 = p1.mul_add(xx, Self::splat(simd, 0.24295)); + let p3 = x * xx; + let x = p2.mul_add(p3, x); + let denom = x.mul_add(x, Self::splat(simd, 1.0)).sqrt(); + x / denom + } + + #[inline] + fn powf(mut self, x: f32) -> Self { + // TODO: SIMD + self[0] = self[0].powf(x); + self[1] = self[1].powf(x); + self[2] = self[2].powf(x); + self[3] = self[3].powf(x); + self[4] = self[4].powf(x); + self[5] = self[5].powf(x); + self[6] = self[6].powf(x); + self[7] = self[7].powf(x); + + self + } +} diff --git a/vendor/vello_cpu/src/fine/highp/blend.rs b/vendor/vello_cpu/src/fine/highp/blend.rs new file mode 100644 index 0000000000..b7ea34ce63 --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/blend.rs @@ -0,0 +1,310 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko::{BlendMode, Mix}; +use crate::util::Premultiply; +use vello_common::fearless_simd::*; + +#[derive(Copy, Clone)] +struct Channels { + r: f32x4, + g: f32x4, + b: f32x4, +} + +impl Channels { + #[inline(always)] + fn unpremultiply(mut self, a: f32x4) -> Self { + self.r = self.r.unpremultiply(a); + self.g = self.g.unpremultiply(a); + self.b = self.b.unpremultiply(a); + + self + } +} + +// TODO: blending is still extremely slow, investigate whether there is something obvious we are +// missing that other renderers do. +pub(crate) fn mix(src_c: f32x16, bg: f32x16, blend_mode: BlendMode) -> f32x16 { + if matches!(blend_mode.mix, Mix::Normal) { + return src_c; + } + // See https://www.w3.org/TR/compositing-1/#blending + let simd = src_c.simd; + + let split = |input: f32x16| { + let mut storage = [0.0; 16]; + simd.store_interleaved_128_f32x16(input, &mut storage); + let input_v = f32x16::from_slice(simd, &storage); + + let p1 = simd.split_f32x16(input_v); + let (r, g) = simd.split_f32x8(p1.0); + let (b, a) = simd.split_f32x8(p1.1); + + (Channels { r, g, b }, a) + }; + + let (bg_channels, bg_a) = split(bg); + let (src_channels, src_a) = split(src_c); + + let unpremultiplied_bg = bg_channels.unpremultiply(bg_a); + let unpremultiplied_src = src_channels.unpremultiply(src_a); + + let mut res_bg = unpremultiplied_bg; + let mix_src = blend_mode.mix(unpremultiplied_src, unpremultiplied_bg); + + let apply_alpha = |unpremultiplied_src_channel: f32x4, + mix_src_channel: f32x4, + dest_channel: &mut f32x4| { + let p1 = (1.0 - bg_a) * unpremultiplied_src_channel; + let p2 = bg_a * mix_src_channel; + + *dest_channel = (p1 + p2).premultiply(src_a); + }; + + apply_alpha(unpremultiplied_src.r, mix_src.r, &mut res_bg.r); + apply_alpha(unpremultiplied_src.g, mix_src.g, &mut res_bg.g); + apply_alpha(unpremultiplied_src.b, mix_src.b, &mut res_bg.b); + + let combined = simd.combine_f32x8( + simd.combine_f32x4(res_bg.r, res_bg.g), + simd.combine_f32x4(res_bg.b, src_a), + ); + + let mut storage = [0.0; 16]; + simd.store_interleaved_128_f32x16(combined, &mut storage); + f32x16::from_slice(simd, &storage) +} + +trait MixExt { + fn mix(&self, src: Channels, bg: Channels) -> Channels; +} + +impl MixExt for BlendMode { + fn mix(&self, src: Channels, bg: Channels) -> Channels { + match self.mix { + Mix::Normal => src, + Mix::Multiply => Multiply::mix(src, bg), + Mix::Screen => Screen::mix(src, bg), + Mix::Overlay => Overlay::mix(src, bg), + Mix::Darken => Darken::mix(src, bg), + Mix::Lighten => Lighten::mix(src, bg), + Mix::ColorDodge => ColorDodge::mix(src, bg), + Mix::ColorBurn => ColorBurn::mix(src, bg), + Mix::HardLight => HardLight::mix(src, bg), + Mix::SoftLight => SoftLight::mix(src, bg), + Mix::Difference => Difference::mix(src, bg), + Mix::Exclusion => Exclusion::mix(src, bg), + Mix::Luminosity => Luminosity::mix(src, bg), + Mix::Color => Color::mix(src, bg), + Mix::Hue => Hue::mix(src, bg), + Mix::Saturation => Saturation::mix(src, bg), + } + } +} + +impl Multiply { + #[inline(always)] + fn single(src: f32x4, bg: f32x4) -> f32x4 { + src * bg + } +} + +impl Screen { + #[inline(always)] + fn single(src: f32x4, bg: f32x4) -> f32x4 { + bg + src - src * bg + } +} + +impl HardLight { + fn single(src: f32x4, bg: f32x4) -> f32x4 { + let two = f32x4::splat(src.simd, 2.0); + + let mask = src.simd.simd_le_f32x4(src, f32x4::splat(src.simd, 0.5)); + let opt1 = Multiply::single(bg, src * two); + let opt2 = Screen::single(bg, two * src - 1.0); + + src.simd.select_f32x4(mask, opt1, opt2) + } +} + +macro_rules! separable_mix { + ($name:ident, $calc:expr) => { + pub(crate) struct $name; + + impl $name { + #[inline(always)] + fn mix(mut src: Channels, bg: Channels) -> Channels { + src.r = $calc(src.r, bg.r); + src.g = $calc(src.g, bg.g); + src.b = $calc(src.b, bg.b); + + src + } + } + }; +} + +separable_mix!(Multiply, |cs: f32x4, cb: f32x4| Multiply::single( + cs, cb +)); +separable_mix!(Screen, |cs: f32x4, cb: f32x4| Screen::single(cs, cb)); +separable_mix!(Overlay, |cs: f32x4, cb: f32x4| HardLight::single( + cb, cs +)); +separable_mix!(Darken, |cs: f32x4, cb: f32x4| cs.min(cb)); +separable_mix!(Lighten, |cs: f32x4, cb: f32x4| cs.max(cb)); +separable_mix!(Difference, |cs: f32x4, cb: f32x4| { + cs.simd + .select_f32x4(cs.simd.simd_le_f32x4(cs, cb), cb - cs, cs - cb) +}); +separable_mix!(HardLight, |cs: f32x4, cb: f32x4| HardLight::single( + cs, cb +)); +separable_mix!(Exclusion, |cs: f32x4, cb: f32x4| { + (cs + cb) - 2.0 * (cs * cb) +}); +separable_mix!(SoftLight, |cs: f32x4, cb: f32x4| { + let mask_1 = cs.simd.simd_le_f32x4(cb, f32x4::splat(cs.simd, 0.25)); + + let d = cs + .simd + .select_f32x4(mask_1, ((16.0 * cb - 12.0) * cb + 4.0) * cb, cb.sqrt()); + + let mask_2 = cs.simd.simd_le_f32x4(cs, f32x4::splat(cs.simd, 0.5)); + + cs.simd.select_f32x4( + mask_2, + cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb), + cb + (2.0 * cs - 1.0) * (d - cb), + ) +}); +separable_mix!(ColorDodge, |cs: f32x4, cb: f32x4| { + let mask_1 = cb.simd.simd_eq_f32x4(cb, f32x4::splat(cb.simd, 0.0)); + let mask_2 = cs.simd.simd_eq_f32x4(cs, f32x4::splat(cs.simd, 1.0)); + + cs.simd.select_f32x4( + // if cb == 0 + mask_1, + f32x4::splat(cs.simd, 0.0), + // else if cs == 1 + cs.simd.select_f32x4( + mask_2, + f32x4::splat(cs.simd, 1.0), + // else + f32x4::splat(cs.simd, 1.0).min(cb / (1.0 - cs)), + ), + ) +}); +separable_mix!(ColorBurn, |cs: f32x4, cb: f32x4| { + let mask_1 = cb.simd.simd_eq_f32x4(cb, f32x4::splat(cb.simd, 1.0)); + let mask_2 = cs.simd.simd_eq_f32x4(cs, f32x4::splat(cs.simd, 0.0)); + + cs.simd.select_f32x4( + // if cb == 1 + mask_1, + f32x4::splat(cs.simd, 1.0), + // else if cs == 0 + cs.simd.select_f32x4( + mask_2, + f32x4::splat(cs.simd, 0.0), + // else + 1.0 - f32x4::splat(cs.simd, 1.0).min((1.0 - cb) / cs), + ), + ) +}); + +macro_rules! non_separable_mix { + ($name:ident, $calc:expr) => { + pub(crate) struct $name; + + impl $name { + #[inline(always)] + fn mix(mut src: Channels, mut bg: Channels) -> Channels { + $calc(&mut src, &mut bg) + } + } + }; +} + +non_separable_mix!(Hue, |cs: &mut Channels, cb: &mut Channels| { + set_sat(&mut cs.r, &mut cs.g, &mut cs.b, sat(cb.r, cb.g, cb.b)); + set_lum(&mut cs.r, &mut cs.g, &mut cs.b, lum(cb.r, cb.g, cb.b)); + + *cs +}); + +non_separable_mix!(Saturation, |cs: &mut Channels, cb: &mut Channels| { + let lum = lum(cb.r, cb.g, cb.b); + set_sat(&mut cb.r, &mut cb.g, &mut cb.b, sat(cs.r, cs.g, cs.b)); + set_lum(&mut cb.r, &mut cb.g, &mut cb.b, lum); + + *cb +}); + +non_separable_mix!(Color, |cs: &mut Channels, cb: &mut Channels| { + set_lum(&mut cs.r, &mut cs.g, &mut cs.b, lum(cb.r, cb.g, cb.b)); + + *cs +}); +non_separable_mix!(Luminosity, |cs: &mut Channels, cb: &mut Channels| { + set_lum(&mut cb.r, &mut cb.g, &mut cb.b, lum(cs.r, cs.g, cs.b)); + + *cb +}); + +fn lum(r: f32x4, g: f32x4, b: f32x4) -> f32x4 { + 0.3 * r + 0.59 * g + 0.11 * b +} + +fn sat(r: f32x4, g: f32x4, b: f32x4) -> f32x4 { + r.max(g).max(b) - r.min(g).min(b) +} + +// PdfCraft patch: backport of linebender/vello#2000. A gray that rounding leaves just below 0 +// or above 1 has `l == n` (or `x == l`), and the unguarded division made a NaN that drew it +// black, whatever the backdrop's alpha; a clip now needs a nonzero denominator. +fn clip_color(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4) { + let simd = r.simd; + let zero = f32x4::splat(simd, 0.0); + + let l = lum(*r, *g, *b); + let n = r.min(g.min(*b)); + let x = r.max(g.max(*b)); + let below = l - n; + let above = x - l; + let clip_low = simd.simd_lt_f32x4(n, zero) & simd.simd_gt_f32x4(below, zero); + let clip_high = + simd.simd_gt_f32x4(x, f32x4::splat(simd, 1.0)) & simd.simd_gt_f32x4(above, zero); + + for c in [r, g, b] { + *c = simd.select_f32x4(clip_low, l + (((*c - l) * l) / below), *c); + *c = simd.select_f32x4(clip_high, l + (((*c - l) * (1.0 - l)) / above), *c); + } +} + +fn set_lum(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4, l: f32x4) { + let d = l - lum(*r, *g, *b); + *r += d; + *g += d; + *b += d; + + clip_color(r, g, b); +} + +// Adapted from tiny-skia +fn set_sat(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4, s: f32x4) { + let simd = r.simd; + let zero = f32x4::splat(simd, 0.0); + let mn = r.min(g.min(*b)); + let mx = r.max(g.max(*b)); + let sat = mx - mn; + + // Map min channel to 0, max channel to s, and scale the middle proportionally. + let scale = |c| simd.select_f32x4(simd.simd_eq_f32x4(sat, zero), zero, (c - mn) * s / sat); + + *r = scale(*r); + *g = scale(*g); + *b = scale(*b); +} diff --git a/vendor/vello_cpu/src/fine/highp/compose.rs b/vendor/vello_cpu/src/fine/highp/compose.rs new file mode 100644 index 0000000000..32983c9abe --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/compose.rs @@ -0,0 +1,171 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::Splat4thExt; +use crate::peniko::{BlendMode, Compose}; +use vello_common::fearless_simd::*; + +pub(crate) trait ComposeExt { + fn compose( + &self, + simd: S, + src_c: f32x16, + bg_c: f32x16, + alpha_mask: Option>, + ) -> f32x16; +} + +impl ComposeExt for BlendMode { + fn compose( + &self, + simd: S, + src_c: f32x16, + bg_c: f32x16, + alpha_mask: Option>, + ) -> f32x16 { + // There some non-obvious subtleties worth highlighting here. + // We support two kinds of blending (in this case, we focus on compositing specifically): + // - Isolated blending, where layers as a whole are blended together with their backdrop. + // If we are currently performing this kind of blending, `alpha_mask` will always be `None`. + // After all, there is no concrete shape opacity associated with a layer. Instead, we are + // just compositing the RGBA values at _all_ positions of the source layer with the backdrop + // layer. For example, if the backdrop contains a green rectangle and source layer is just + // empty, if we perform blending with `Compose::Clear`, then _everything_ will be cleared, + // because we are compositing the whole source layer with the whole backdrop, and not + // just the parts of the source layer that have actually be drawn on. + // - Non-isolated blending, where a single path is blended with the backdrop. In this case, + // `alpha_mask` _might_ be `Some` and contain the alpha values of the strips we are currently + // compositing. Remember that strips always have a fixed height of 4, because of this, the + // strips might cover areas that aren't actually covered by the path (and just have an alpha + // value of 0, or a value between 0-254 for anti-aliased parts). Because of this, for + // non-isolated blending, we need to lerp the result with the backdrop using `alpha_mask`. + + let mut res = match self.compose { + Compose::SrcOver => SrcOver::compose(simd, src_c, bg_c), + Compose::Clear => Clear::compose(simd, src_c, bg_c), + Compose::Copy => Copy::compose(simd, src_c, bg_c), + Compose::DestOver => DestOver::compose(simd, src_c, bg_c), + Compose::Dest => Dest::compose(simd, src_c, bg_c), + Compose::SrcIn => SrcIn::compose(simd, src_c, bg_c), + Compose::DestIn => DestIn::compose(simd, src_c, bg_c), + Compose::SrcOut => SrcOut::compose(simd, src_c, bg_c), + Compose::DestOut => DestOut::compose(simd, src_c, bg_c), + Compose::SrcAtop => SrcAtop::compose(simd, src_c, bg_c), + Compose::DestAtop => DestAtop::compose(simd, src_c, bg_c), + Compose::Xor => Xor::compose(simd, src_c, bg_c), + Compose::Plus => Plus::compose(simd, src_c, bg_c), + // Have not been able to find a formula for this, so just fallback to Plus. + Compose::PlusLighter => Plus::compose(simd, src_c, bg_c), + }; + + if let Some(alpha_mask) = alpha_mask { + let alpha_mask_inv = 1.0 - alpha_mask; + res = alpha_mask * res + alpha_mask_inv * bg_c; + } + + res + } +} + +macro_rules! compose { + ($name:ident, $fa:expr, $fb:expr, $sat:expr) => { + struct $name; + + impl $name { + fn compose(simd: S, src_c: f32x16, bg_c: f32x16) -> f32x16 { + let al_b = bg_c.splat_4th(); + let al_s = src_c.splat_4th(); + + let fa = $fa(simd, al_s, al_b); + let fb = $fb(simd, al_s, al_b); + + if $sat { + (src_c * fa + fb * bg_c) + .min(f32x16::splat(simd, 1.0)) + .max(f32x16::splat(simd, 0.0)) + } else { + src_c * fa + fb * bg_c + } + } + } + }; +} + +compose!( + Clear, + |simd, _, _| f32x16::splat(simd, 0.0), + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + Copy, + |simd, _, _| f32x16::splat(simd, 1.0), + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + SrcOver, + |simd, _, _| f32x16::splat(simd, 1.0), + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + DestOver, + |_, _, al_b: f32x16| 1.0 - al_b, + |simd, _, _| f32x16::splat(simd, 1.0), + false +); +compose!( + Dest, + |simd, _, _| f32x16::splat(simd, 0.0), + |simd, _, _| f32x16::splat(simd, 1.0), + false +); +compose!( + Xor, + |_, _, al_b: f32x16| 1.0 - al_b, + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + SrcIn, + |_, _, al_b: f32x16| al_b, + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + DestIn, + |simd, _, _| f32x16::splat(simd, 0.0), + |_, al_s: f32x16, _| al_s, + false +); +compose!( + SrcOut, + |_, _, al_b: f32x16| 1.0 - al_b, + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + DestOut, + |simd, _, _| f32x16::splat(simd, 0.0), + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + SrcAtop, + |_, _, al_b: f32x16| al_b, + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + DestAtop, + |_, _, al_b: f32x16| 1.0 - al_b, + |_, al_s: f32x16, _| al_s, + false +); +compose!( + Plus, + |simd, _, _| f32x16::splat(simd, 1.0), + |simd, _, _| f32x16::splat(simd, 1.0), + true +); diff --git a/vendor/vello_cpu/src/fine/highp/mod.rs b/vendor/vello_cpu/src/fine/highp/mod.rs new file mode 100644 index 0000000000..0948001bee --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/mod.rs @@ -0,0 +1,544 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! High-precision (f32) rendering kernel implementation. +//! +//! This module implements the fine rasterization stage using 32-bit floating-point +//! values for color components. This provides maximum precision and color accuracy, +//! at the cost of higher memory bandwidth and potentially slower performance compared +//! to the low-precision u8 kernel. +//! +//! The f32 kernel is particularly useful for: +//! - Scenes requiring high precision (e.g., gradients with subtle color transitions) +//! - Debugging and reference implementations +//! - Platforms where SIMD f32 operations are well-optimized + +use crate::filter::filter_highp; +use crate::fine::FineKernel; +use crate::fine::{COLOR_COMPONENTS, Painter, Splat4thExt}; +use crate::layer_manager::LayerManager; +use crate::peniko::BlendMode; +use crate::region::Region; +use vello_common::fearless_simd::*; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{PremulColor, Tint, TintMode}; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; + +pub(crate) mod blend; +pub(crate) mod compose; + +/// The kernel for doing rendering using f32. +#[derive(Clone, Copy, Debug)] +pub struct F32Kernel; + +impl FineKernel for F32Kernel { + type Numeric = f32; + type Composite = f32x16; + type NumericVec = f32x16; + + /// Extracts RGBA color components from a premultiplied color as f32 values [0.0, 1.0]. + #[inline(always)] + fn extract_color(color: PremulColor) -> [Self::Numeric; 4] { + color.as_premul_f32().components + } + + /// Copies rendered pixels from the scratch buffer to the output region. + /// + /// Converts f32 color values in [0.0, 1.0] range to u8 [0, 255] with rounding, + /// transforming from column-major scratch buffer to row-major region layout. + #[inline(always)] + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]) { + simd.vectorize( + #[inline(always)] + || { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region + .row_mut(y) + .chunks_exact_mut(COLOR_COMPONENTS) + .enumerate() + { + let idx = + COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + let start = &blend_buf[idx..]; + // Convert f32 [0.0, 1.0] to u8 [0, 255] with rounding. + // TODO: Use explicit SIMD for better performance + let converted = [ + (start[0] * 255.0 + 0.5) as u8, + (start[1] * 255.0 + 0.5) as u8, + (start[2] * 255.0 + 0.5) as u8, + (start[3] * 255.0 + 0.5) as u8, + ]; + pixel.copy_from_slice(&converted); + } + } + }, + ); + } + + /// Copies pixels from the output region to the scratch buffer. + /// + /// Converts u8 color values [0, 255] to normalized f32 [0.0, 1.0], + /// transforming from row-major region layout to column-major scratch buffer. + /// This is the inverse operation of `pack`. + #[inline(always)] + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]) { + simd.vectorize( + #[inline(always)] + || { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region.row_mut(y).chunks_exact(COLOR_COMPONENTS).enumerate() { + let idx = + COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + let start = &mut blend_buf[idx..]; + // Convert u8 [0, 255] to normalized f32 [0.0, 1.0] (reverse of pack) + start[0] = pixel[0] as f32 / 255.0; + start[1] = pixel[1] as f32 / 255.0; + start[2] = pixel[2] as f32 / 255.0; + start[3] = pixel[3] as f32 / 255.0; + } + } + }, + ); + } + + /// Applies a filter effect to a rendered layer. + /// + /// Delegates to the f32-specific filter implementation. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + filter_highp(filter, pixmap, layer_manager, transform); + } + + /// Fills a buffer with a solid color using SIMD operations. + /// + /// Efficiently broadcasts a single RGBA color across all pixels in the destination. + #[inline(never)] + fn copy_solid(simd: S, dest: &mut [Self::Numeric], src: [Self::Numeric; 4]) { + simd.vectorize( + #[inline(always)] + || { + let color = f32x16::block_splat(src.simd_into(simd)); + + for el in dest.chunks_exact_mut(16) { + color.store_slice(el); + } + }, + ); + } + + /// Applies per-pixel mask values to a buffer by multiplying each component. + /// + /// Used for anti-aliasing and clipping effects. Each pixel is multiplied by + /// its corresponding mask value (already normalized to [0.0, 1.0]). + fn apply_mask( + simd: S, + dest: &mut [Self::Numeric], + mut src: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + for el in dest.chunks_exact_mut(16) { + let loaded = f32x16::from_slice(simd, el); + let mulled = loaded * src.next().unwrap(); + mulled.store_slice(el); + } + }, + ); + } + + /// Applies a painter's output to the destination buffer. + /// + /// Delegates to the painter's f32-specific implementation. + #[inline(always)] + fn apply_painter<'a>(_: S, dest: &mut [Self::Numeric], mut painter: impl Painter + 'a) { + painter.paint_f32(dest); + } + + #[inline(always)] + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint) { + let premul = tint.color.premultiply(); + let [r, g, b, a] = premul.components; + let tint_v = f32x16::block_splat(f32x4::from_slice(simd, &[r, g, b, a])); + + simd.vectorize( + #[inline(always)] + || match tint.mode { + TintMode::AlphaMask => { + for chunk in dest.chunks_exact_mut(16) { + let pixel = f32x16::from_slice(simd, chunk); + let alphas = pixel.splat_4th(); + let tinted = tint_v * alphas; + tinted.store_slice(chunk); + } + } + TintMode::Multiply => { + for chunk in dest.chunks_exact_mut(16) { + let pixel = f32x16::from_slice(simd, chunk); + let tinted = pixel * tint_v; + tinted.store_slice(chunk); + } + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. + #[inline(always)] + fn alpha_composite_solid( + simd: S, + dest: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_solid( + simd, + dest, + src, + bytemuck::cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_solid(simd, dest, src); + } + } + + /// Composites a source buffer onto a destination buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. Each source pixel's alpha determines + /// the blending amount. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_arbitrary( + simd, + dest, + src.chunks_exact(16).map(|el| f32x16::from_slice(simd, el)), + bytemuck::cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_arbitrary( + simd, + dest, + src.chunks_exact(16).map(|el| f32x16::from_slice(simd, el)), + ); + } + } + + /// Applies a blend mode to composite source pixels onto destination. + /// + /// Dispatches to either the masked or unmasked blend implementation. + /// Handles both color mixing (multiply, screen, etc.) and compositing. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + mut start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let alpha_iter = alphas.map(|a| bytemuck::cast_slice::(a).iter().copied()); + + let mask_iter = mask.map(|m| { + let width = m.width(); + let height = m.height(); + + core::iter::from_fn(move || { + let samples = if start_x < width && start_y + 3 < height { + // All in bounds, sample directly + [ + m.sample(start_x, start_y), + m.sample(start_x, start_y + 1), + m.sample(start_x, start_y + 2), + m.sample(start_x, start_y + 3), + ] + } else { + // Fallback: check each individually + [ + if start_x < width && start_y < height { + m.sample(start_x, start_y) + } else { + 255 + }, + if start_x < width && start_y + 1 < height { + m.sample(start_x, start_y + 1) + } else { + 255 + }, + if start_x < width && start_y + 2 < height { + m.sample(start_x, start_y + 2) + } else { + 255 + }, + if start_x < width && start_y + 3 < height { + m.sample(start_x, start_y + 3) + } else { + 255 + }, + ] + }; + + start_x += 1; + + Some(samples) + }) + }); + + match (alpha_iter, mask_iter) { + (Some(alpha_iter), Some(mut mask_iter)) => { + let iter = alpha_iter.map(|a1| { + let a2 = mask_iter.next().unwrap(); + [ + ((a1[0] as u16 * a2[0] as u16) / 255) as u8, + ((a1[1] as u16 * a2[1] as u16) / 255) as u8, + ((a1[2] as u16 * a2[2] as u16) / 255) as u8, + ((a1[3] as u16 * a2[3] as u16) / 255) as u8, + ] + }); + alpha_fill::blend(simd, dest, src, iter, blend_mode); + } + (None, Some(mask_iter)) => alpha_fill::blend(simd, dest, src, mask_iter, blend_mode), + (Some(alpha_iter), None) => alpha_fill::blend(simd, dest, src, alpha_iter, blend_mode), + (None, None) => { + fill::blend(simd, dest, src, blend_mode); + } + } + } +} + +mod fill { + //! Alpha compositing and blending operations without per-pixel alpha masks. + //! + //! This module handles the case where we're compositing full opacity pixels, + //! using only the source alpha channel for compositing. + + use crate::fine::Splat4thExt; + use crate::fine::highp::blend; + use crate::fine::highp::compose::ComposeExt; + use crate::peniko::BlendMode; + + use vello_common::fearless_simd::*; + + // IMPORTANT: The inlining attributes (#[inline(always)], #[inline(never)]) in this + // module have been carefully tuned through benchmarking. Changing them can cause + // significant performance regressions. + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Uses the "over" operator: `result = src + bg * (1 - src_alpha)` + #[inline(always)] + pub(super) fn alpha_composite_solid(s: S, dest: &mut [f32], src: [f32; 4]) { + s.vectorize( + #[inline(always)] + || { + let one_minus_alpha = 1.0 - f32x16::block_splat(f32x4::splat(s, src[3])); + let src_c = f32x16::block_splat(f32x4::simd_from(s, src)); + + for next_dest in dest.chunks_exact_mut(16) { + alpha_composite_inner(s, next_dest, src_c, one_minus_alpha); + } + }, + ); + } + + /// Composites a buffer of colors onto another buffer using alpha blending. + /// + /// Each source pixel is composited individually based on its alpha channel. + #[inline(always)] + pub(super) fn alpha_composite_arbitrary>>( + simd: S, + dest: &mut [f32], + src: T, + ) { + simd.vectorize( + #[inline(always)] + || { + for (next_dest, next_src) in dest.chunks_exact_mut(16).zip(src) { + let one_minus_alpha = 1.0 - next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, next_src, one_minus_alpha); + } + }, + ); + } + + /// Applies blend mode compositing to a buffer without per-pixel masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [f32], + src: T, + blend_mode: BlendMode, + ) { + for (next_dest, next_src) in dest.chunks_exact_mut(16).zip(src) { + let bg_v = f32x16::from_slice(simd, next_dest); + let src_c = blend::mix(next_src, bg_v, blend_mode); + let res = blend_mode.compose(simd, src_c, bg_v, None); + res.store_slice(next_dest); + } + } + + /// Performs the core alpha compositing calculation. + /// + /// Formula: `result = src + bg * (1 - src_alpha)` + /// This implements the Porter-Duff "source over" operator using FMA for efficiency. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [f32], + src: f32x16, + one_minus_alpha: f32x16, + ) { + let mut bg_c = f32x16::from_slice(s, dest); + bg_c = one_minus_alpha.mul_add(bg_c, src); + bg_c.store_slice(dest); + } +} + +mod alpha_fill { + //! Alpha compositing and blending operations with per-pixel alpha masks. + //! + //! This module handles compositing when each pixel has an additional mask value + //! (e.g., from anti-aliasing or clip masks) that modulates the source alpha. + + use crate::fine::Splat4thExt; + use crate::fine::highp::compose::ComposeExt; + use crate::fine::highp::{blend, extract_masks}; + use crate::peniko::BlendMode; + use vello_common::fearless_simd::*; + + /// Composites a solid color with per-pixel alpha masks. + /// + /// Combines source alpha with mask values: `effective_alpha = src_alpha * mask` + #[inline(always)] + pub(super) fn alpha_composite_solid( + s: S, + dest: &mut [f32], + src: [f32; 4], + alphas: impl Iterator, + ) { + s.vectorize( + #[inline(always)] + || { + let src_a = f32x16::splat(s, src[3]); + let src_c = f32x16::block_splat(src.simd_into(s)); + let one = f32x16::splat(s, 1.0); + + for (next_dest, next_mask) in dest.chunks_exact_mut(16).zip(alphas) { + alpha_composite_inner(s, next_dest, &next_mask, src_c, src_a, one); + } + }, + ); + } + + /// Composites a buffer of colors with per-pixel alpha masks. + /// + /// Each pixel's source alpha is modulated by its corresponding mask value. + pub(super) fn alpha_composite_arbitrary>>( + simd: S, + dest: &mut [f32], + src: T, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let one = f32x16::splat(simd, 1.0); + + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(16).zip(alphas).zip(src) + { + let src_a = next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, &next_mask, next_src, src_a, one); + } + }, + ); + } + + /// Applies blend mode compositing with per-pixel alpha masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [f32], + src: T, + alphas: impl Iterator, + blend_mode: BlendMode, + ) { + simd.vectorize( + #[inline(always)] + || { + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(16).zip(alphas).zip(src) + { + let masks = extract_masks(simd, &next_mask); + let bg = f32x16::from_slice(simd, next_dest); + let src_c = blend::mix(next_src, bg, blend_mode); + let res = blend_mode.compose(simd, src_c, bg, Some(masks)); + res.store_slice(next_dest); + } + }, + ); + } + + /// Performs alpha compositing with mask modulation. + /// + /// Formula: `result = src * mask + bg * (1 - src_alpha * mask)` + /// The mask value modulates both the source contribution and the inverse alpha. + /// Uses FMA instructions for optimal performance. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [f32], + masks: &[u8; 4], + src_c: f32x16, + src_a: f32x16, + one: f32x16, + ) { + let bg_c = f32x16::from_slice(s, dest); + let mask_a = extract_masks(s, masks); + // 1 - src_a * mask_a + let inv_src_a_mask_a = src_a.mul_add(-mask_a, one); + + let res = bg_c.mul_add(inv_src_a_mask_a, src_c * mask_a); + res.store_slice(dest); + } +} + +/// Expands 4 mask bytes into a 16-element f32 SIMD vector with normalized values. +/// +/// Converts u8 mask values to f32 in range [0.0, 1.0], then duplicates each mask +/// value across 4 consecutive elements (one per color component). +/// +/// Input: [m0, m1, m2, m3] (as u8, 0-255) +/// Output: [m0/255, m0/255, m0/255, m0/255, m1/255, ..., m3/255] (as f32, 16 elements) +#[inline(always)] +fn extract_masks(simd: S, masks: &[u8; 4]) -> f32x16 { + let mut base_mask = [ + masks[0] as f32, + masks[1] as f32, + masks[2] as f32, + masks[3] as f32, + ] + .simd_into(simd); + + base_mask *= f32x4::splat(simd, 1.0 / 255.0); + + let res = f32x16::block_splat(base_mask); + let zip_low = res.zip_low(res); + + zip_low.zip_low(zip_low) +} diff --git a/vendor/vello_cpu/src/fine/lowp/compose.rs b/vendor/vello_cpu/src/fine/lowp/compose.rs new file mode 100644 index 0000000000..c03d43bd4a --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/compose.rs @@ -0,0 +1,161 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::Splat4thExt; +use crate::peniko::{BlendMode, Compose}; +use crate::util::NormalizedMulExt; +use vello_common::fearless_simd::*; +use vello_common::util::Div255Ext; + +pub(crate) trait ComposeExt { + fn compose( + &self, + simd: S, + src_c: u8x32, + bg_c: u8x32, + alpha_mask: Option>, + ) -> u8x32; +} + +impl ComposeExt for BlendMode { + fn compose( + &self, + simd: S, + src_c: u8x32, + bg_c: u8x32, + alpha_mask: Option>, + ) -> u8x32 { + let mut res = match self.compose { + Compose::SrcOver => SrcOver::compose(simd, src_c, bg_c), + Compose::Clear => Clear::compose(simd, src_c, bg_c), + Compose::Copy => Copy::compose(simd, src_c, bg_c), + Compose::DestOver => DestOver::compose(simd, src_c, bg_c), + Compose::Dest => Dest::compose(simd, src_c, bg_c), + Compose::SrcIn => SrcIn::compose(simd, src_c, bg_c), + Compose::DestIn => DestIn::compose(simd, src_c, bg_c), + Compose::SrcOut => SrcOut::compose(simd, src_c, bg_c), + Compose::DestOut => DestOut::compose(simd, src_c, bg_c), + Compose::SrcAtop => SrcAtop::compose(simd, src_c, bg_c), + Compose::DestAtop => DestAtop::compose(simd, src_c, bg_c), + Compose::Xor => Xor::compose(simd, src_c, bg_c), + Compose::Plus => Plus::compose(simd, src_c, bg_c), + // Have not been able to find a formula for this, so just fallback to Plus. + Compose::PlusLighter => Plus::compose(simd, src_c, bg_c), + }; + + if let Some(alpha_mask) = alpha_mask { + let alpha_mask_inv = 255 - alpha_mask; + let p1 = simd.widen_u8x32(alpha_mask) * simd.widen_u8x32(res); + let p2 = simd.widen_u8x32(alpha_mask_inv) * simd.widen_u8x32(bg_c); + res = simd.narrow_u16x32((p1 + p2).div_255()); + } + + res + } +} + +macro_rules! compose { + ($name:ident, $fa:expr, $fb:expr, $sat:expr) => { + struct $name; + + impl $name { + fn compose(simd: S, src_c: u8x32, bg_c: u8x32) -> u8x32 { + let al_b = bg_c.splat_4th(); + let al_s = src_c.splat_4th(); + + let fa = $fa(simd, al_s, al_b); + let fb = $fb(simd, al_s, al_b); + + if $sat { + simd.narrow_u16x32( + (simd.widen_u8x32(src_c.normalized_mul(fa)) + + simd.widen_u8x32(fb.normalized_mul(bg_c))) + .min(u16x32::splat(simd, 255)) + .max(u16x32::splat(simd, 0)), + ) + } else { + src_c.normalized_mul(fa) + fb.normalized_mul(bg_c) + } + } + } + }; +} + +compose!( + Clear, + |simd, _, _| u8x32::splat(simd, 0), + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + Copy, + |simd, _, _| u8x32::splat(simd, 255), + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + SrcOver, + |simd, _, _| u8x32::splat(simd, 255), + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + DestOver, + |_, _, al_b: u8x32| 255 - al_b, + |simd, _, _| u8x32::splat(simd, 255), + false +); +compose!( + Dest, + |simd, _, _| u8x32::splat(simd, 0), + |simd, _, _| u8x32::splat(simd, 255), + false +); +compose!( + Xor, + |_, _, al_b: u8x32| 255 - al_b, + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + SrcIn, + |_, _, al_b: u8x32| al_b, + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + DestIn, + |simd, _, _| u8x32::splat(simd, 0), + |_, al_s: u8x32, _| al_s, + false +); +compose!( + SrcOut, + |_, _, al_b: u8x32| 255 - al_b, + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + DestOut, + |simd, _, _| u8x32::splat(simd, 0), + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + SrcAtop, + |_, _, al_b: u8x32| al_b, + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + DestAtop, + |_, _, al_b: u8x32| 255 - al_b, + |_, al_s: u8x32, _| al_s, + false +); +compose!( + Plus, + |simd, _, _| u8x32::splat(simd, 255), + |simd, _, _| u8x32::splat(simd, 255), + true +); diff --git a/vendor/vello_cpu/src/fine/lowp/gradient.rs b/vendor/vello_cpu/src/fine/lowp/gradient.rs new file mode 100644 index 0000000000..210818a524 --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/gradient.rs @@ -0,0 +1,84 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko; +use core::slice::ChunksExact; +use vello_common::encode::EncodedGradient; +use vello_common::fearless_simd::*; + +/// An accelerated gradient painter for u8. +/// +/// Assumes that the gradient has no undefined positions. +#[derive(Debug)] +pub(crate) struct GradientPainter<'a, S: Simd> { + gradient: &'a EncodedGradient, + lut: &'a [[u8; 4]], + t_vals: ChunksExact<'a, f32>, + scale_factor: f32x16, + simd: S, +} + +impl<'a, S: Simd> GradientPainter<'a, S> { + pub(crate) fn new(simd: S, gradient: &'a EncodedGradient, t_vals: &'a [f32]) -> Self { + let lut = gradient.u8_lut(simd); + let scale_factor = f32x16::splat(simd, lut.scale_factor()); + + Self { + gradient, + scale_factor, + lut: lut.lut(), + t_vals: t_vals.chunks_exact(16), + simd, + } + } +} + +impl Iterator for GradientPainter<'_, S> { + type Item = u8x64; + + #[inline(always)] + fn next(&mut self) -> Option { + let extend = self.gradient.extend; + let pos = f32x16::from_slice(self.simd, self.t_vals.next()?); + let t_vals = apply_extend(pos, extend); + let indices = (t_vals * self.scale_factor).to_int::>(); + + let mut vals = [0_u8; 64]; + for (val, idx) in vals.chunks_exact_mut(4).zip(*indices) { + val.copy_from_slice(&self.lut[idx as usize]); + } + + Some(u8x64::from_slice(self.simd, &vals)) + } +} + +impl crate::fine::Painter for GradientPainter<'_, S> { + fn paint_u8(&mut self, buf: &mut [u8]) { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(64) { + self.next().unwrap().store_slice(chunk); + } + }, + ); + } + + fn paint_f32(&mut self, _: &mut [f32]) { + unimplemented!() + } +} + +// TODO: Maybe delete this method and use `apply_extend` from highp by splitting into two f32x8. +#[inline(always)] +pub(crate) fn apply_extend(val: f32x16, extend: peniko::Extend) -> f32x16 { + match extend { + peniko::Extend::Pad => val.max(0.0).min(1.0), + peniko::Extend::Repeat => (val - val.floor()).fract(), + // See + peniko::Extend::Reflect => ((val - 1.0) - 2.0 * ((val - 1.0) * 0.5).floor() - 1.0) + .abs() + .max(0.0) + .min(1.0), + } +} diff --git a/vendor/vello_cpu/src/fine/lowp/image.rs b/vendor/vello_cpu/src/fine/lowp/image.rs new file mode 100644 index 0000000000..f68e2162cf --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/image.rs @@ -0,0 +1,259 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::PosExt; +use crate::fine::common::image::{ImagePainterData, extend, fract_floor, sample}; +use crate::fine::macros::u8x16_painter; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x4, u8x16, u16x16}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::element_wise_splat; +use vello_common::util::{Div255Ext, f32_to_u8}; + +/// A faster bilinear image renderer for the u8 pipeline. +#[derive(Debug)] +pub(crate) struct BilinearImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd> BilinearImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for BilinearImagePainter<'_, S> { + type Item = u8x16; + + fn next(&mut self) -> Option { + let x_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ); + + let y_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ); + + let extend_x = |x_pos: f32x4| { + extend( + self.simd, + x_pos, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ) + }; + + let extend_y = |y_pos: f32x4| { + extend( + self.simd, + y_pos, + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ) + }; + + let fx = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(x_positions + 0.5).mul_add(255.0, 0.5), + )); + let fy = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(y_positions + 0.5).mul_add(255.0, 0.5), + )); + + let fx = self.simd.widen_u8x16(fx); + let fy = self.simd.widen_u8x16(fy); + let fx_inv = u16x16::splat(self.simd, 255) - fx; + let fy_inv = u16x16::splat(self.simd, 255) - fy; + + let x_pos1 = extend_x(x_positions - 0.5); + let x_pos2 = extend_x(x_positions + 0.5); + let y_pos1 = extend_y(y_positions - 0.5); + let y_pos2 = extend_y(y_positions + 0.5); + + let p00 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, y_pos1)); + let p10 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, y_pos1)); + let p01 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, y_pos2)); + let p11 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, y_pos2)); + + let ip1 = (p00 * fx_inv + p10 * fx).div_255(); + let ip2 = (p01 * fx_inv + p11 * fx).div_255(); + let res = self.simd.narrow_u16x16((ip1 * fy_inv + ip2 * fy).div_255()); + + self.data.cur_pos += self.data.image.x_advance; + + Some(res) + } +} + +u8x16_painter!(BilinearImagePainter<'_, S>); + +/// A faster bilinear image renderer for axis-aligned images (no skew) in the u8 pipeline. +/// +/// This is an optimized version of `BilinearImagePainter` that pre-computes y positions +/// and interpolation weights since they don't change when there's no skew. +#[derive(Debug)] +pub(crate) struct PlainBilinearImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, + /// Pre-computed y sample positions (top row for bilinear grid) + y_pos1: f32x4, + /// Pre-computed y sample positions (bottom row for bilinear grid) + y_pos2: f32x4, + /// Pre-computed y interpolation weight + fy: u16x16, + /// Pre-computed inverse y interpolation weight + fy_inv: u16x16, + /// Current x position + cur_x_pos: f32x4, + /// X advance per iteration + advance: f32, +} + +impl<'a, S: Simd> PlainBilinearImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + // For axis-aligned images, y doesn't change across the strip + let y_positions = f32x4::splat_pos( + simd, + data.cur_pos.y as f32, + data.x_advances.1, + data.y_advances.1, + ); + + // Pre-compute y extend positions + let y_pos1 = extend( + simd, + y_positions - 0.5, + image.sampler.y_extend, + data.height, + data.height_inv, + ); + let y_pos2 = extend( + simd, + y_positions + 0.5, + image.sampler.y_extend, + data.height, + data.height_inv, + ); + + // Pre-compute y interpolation weights + let fy = f32_to_u8(element_wise_splat( + simd, + fract_floor(y_positions + 0.5).mul_add(255.0, 0.5), + )); + let fy = simd.widen_u8x16(fy); + let fy_inv = u16x16::splat(simd, 255) - fy; + + let cur_x_pos = f32x4::splat_pos( + simd, + data.cur_pos.x as f32, + data.x_advances.0, + data.y_advances.0, + ); + + Self { + data, + y_pos1, + y_pos2, + fy, + fy_inv, + cur_x_pos, + advance: image.x_advance.x as f32, + simd, + } + } +} + +impl Iterator for PlainBilinearImagePainter<'_, S> { + type Item = u8x16; + + #[inline(always)] + fn next(&mut self) -> Option { + let x_minus_half = self.cur_x_pos - 0.5; + let x_plus_half = self.cur_x_pos + 0.5; + + // Only x needs to be extended per-iteration + let x_pos1 = extend( + self.simd, + x_minus_half, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + let x_pos2 = extend( + self.simd, + x_plus_half, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + // Compute x interpolation weights + let fx = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(x_plus_half).mul_add(255.0, 0.5), + )); + let fx = self.simd.widen_u8x16(fx); + let fx_inv = u16x16::splat(self.simd, 255) - fx; + + // Sample the 4 corners using pre-computed y positions + let p00 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, self.y_pos1)); + let p10 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, self.y_pos1)); + let p01 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, self.y_pos2)); + let p11 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, self.y_pos2)); + + // Bilinear interpolation + let ip1 = (p00 * fx_inv + p10 * fx).div_255(); + let ip2 = (p01 * fx_inv + p11 * fx).div_255(); + let res = self + .simd + .narrow_u16x16((ip1 * self.fy_inv + ip2 * self.fy).div_255()); + + self.cur_x_pos += self.advance; + + Some(res) + } +} + +u8x16_painter!(PlainBilinearImagePainter<'_, S>); diff --git a/vendor/vello_cpu/src/fine/lowp/mod.rs b/vendor/vello_cpu/src/fine/lowp/mod.rs new file mode 100644 index 0000000000..c59d68122d --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/mod.rs @@ -0,0 +1,770 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Low-precision (u8/u16) rendering kernel implementation. +//! +//! This module implements the fine rasterization stage using 8-bit unsigned integers +//! for color values and 16-bit for intermediate calculations. This provides better +//! performance on many architectures compared to floating-point operations, while +//! maintaining sufficient precision for most rendering tasks. + +mod compose; +mod gradient; +mod image; + +use crate::filter::filter_lowp; +use crate::fine::lowp::image::{BilinearImagePainter, PlainBilinearImagePainter}; +use crate::fine::{COLOR_COMPONENTS, Painter, SCRATCH_BUF_SIZE, Splat4thExt}; +use crate::fine::{FineKernel, highp, u8_to_f32}; +use crate::layer_manager::LayerManager; +use crate::peniko::BlendMode; +use crate::region::Region; +use crate::util::NormalizedMulExt; +use crate::util::scalar::div_255; +use bytemuck::cast_slice; +use core::iter; +use vello_common::coarse::WideTile; +use vello_common::encode::{EncodedGradient, EncodedImage}; +use vello_common::fearless_simd::*; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{PremulColor, Tint, TintMode}; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; +use vello_common::util::{Div255Ext, f32_to_u8}; + +/// The kernel for doing rendering using u8/u16. +#[derive(Clone, Copy, Debug)] +pub struct U8Kernel; + +impl FineKernel for U8Kernel { + type Numeric = u8; + type Composite = u8x32; + type NumericVec = u8x16; + + /// Extracts RGBA color components from a premultiplied color as u8 values [0, 255]. + #[inline] + fn extract_color(color: PremulColor) -> [Self::Numeric; 4] { + color.as_premul_rgba8().to_u8_array() + } + + /// Copies rendered pixels from the scratch buffer to the output region. + /// + /// Converts from column-major scratch buffer layout to row-major region layout, + /// using either a SIMD-optimized path for full tiles or a scalar fallback. + #[inline(always)] + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]) { + if region.width != WideTile::WIDTH || region.height != Tile::HEIGHT { + // Use scalar path for non-standard tile sizes. Wrapping this in `vectorize` + // degrades performance significantly on SSE4.2. + pack(region, blend_buf); + } else { + simd.vectorize( + #[inline(always)] + || { + pack_block(simd, region, blend_buf); + }, + ); + } + } + + /// Copies pixels from the output region to the scratch buffer. + /// + /// Converts from row-major region layout to column-major scratch buffer layout. + /// This is the inverse operation of `pack`. + #[inline(always)] + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]) { + if region.width != WideTile::WIDTH || region.height != Tile::HEIGHT { + // Use scalar path for non-standard tile sizes. + // Note that right now, this path is unused (only for benchmarking), because when + // using filters we always allocate pixmaps of the same size as a wide tile. + // Nevertheless, we still keep this function here for reference, or in case that + // changes in the future. + unpack(region, blend_buf); + } else { + simd.vectorize( + #[inline(always)] + || { + unpack_block(simd, region, blend_buf); + }, + ); + } + } + + /// Applies a filter effect to a rendered layer. + /// + /// Delegates to the u8-specific filter implementation. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + filter_lowp(filter, pixmap, layer_manager, transform); + } + + /// Fills a buffer with a solid color using SIMD operations. + /// + /// Efficiently broadcasts a single RGBA color across all pixels in the destination. + fn copy_solid(simd: S, dest: &mut [Self::Numeric], src: [Self::Numeric; 4]) { + simd.vectorize( + #[inline(always)] + || { + let target: &mut [u32] = bytemuck::cast_slice_mut(dest); + target.fill(u32::from_ne_bytes(src)); + }, + ); + } + + /// Creates a painter for rendering gradients in u8 precision. + /// + /// Returns a painter that evaluates the gradient at each pixel position. + fn gradient_painter<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || gradient::GradientPainter::new(simd, gradient, t_vals), + ) + } + + /// Creates a painter for rendering images with bilinear filtering in u8 precision. + /// + /// Returns a painter that samples the image with bilinear interpolation. + fn medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || BilinearImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Creates a painter for rendering axis-aligned images with bilinear filtering in u8 precision. + /// + /// Returns a painter that samples the image with bilinear interpolation. + /// This is an optimized version for images without skew transformation. + fn plain_medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || PlainBilinearImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Applies per-pixel mask values to a buffer by multiplying each component. + /// + /// Used for anti-aliasing and clipping effects. Each pixel is multiplied by + /// its corresponding mask value and normalized. + fn apply_mask( + simd: S, + dest: &mut [Self::Numeric], + mut src: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + for el in dest.chunks_exact_mut(16) { + let loaded = u8x16::from_slice(simd, el); + let mulled = simd.narrow_u16x16( + (simd.widen_u8x16(loaded) * simd.widen_u8x16(src.next().unwrap())) + .div_255(), + ); + mulled.store_slice(el); + } + }, + ); + } + + /// Applies a painter's output to the destination buffer. + /// + /// Delegates to the painter's u8-specific implementation. + #[inline(always)] + fn apply_painter<'a>(_: S, dest: &mut [Self::Numeric], mut painter: impl Painter + 'a) { + painter.paint_u8(dest); + } + + #[inline(always)] + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint) { + let premul = tint.color.premultiply(); + let [r, g, b, a] = premul.components; + let to_u8 = |v: f32| (v * 255.0 + 0.5) as u8; + let color = u32::from_ne_bytes([to_u8(r), to_u8(g), to_u8(b), to_u8(a)]); + let tint_v = u32x8::block_splat(u32x4::splat(simd, color)).to_bytes(); + + simd.vectorize( + #[inline(always)] + || match tint.mode { + TintMode::AlphaMask => { + for chunk in dest.chunks_exact_mut(32) { + let pixel = u8x32::from_slice(simd, chunk); + let alphas = pixel.splat_4th(); + let tinted = tint_v.normalized_mul(alphas); + tinted.store_slice(chunk); + } + } + TintMode::Multiply => { + for chunk in dest.chunks_exact_mut(32) { + let pixel = u8x32::from_slice(simd, chunk); + let tinted = pixel.normalized_mul(tint_v); + tinted.store_slice(chunk); + } + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. + #[inline(always)] + fn alpha_composite_solid( + simd: S, + dest: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_solid( + simd, + dest, + src, + cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_solid(simd, dest, src); + } + } + + /// Composites a source buffer onto a destination buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. Each source pixel's alpha determines + /// the blending amount. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ) { + let src_iter = src.chunks_exact(32).map(|el| u8x32::from_slice(simd, el)); + + if let Some(alphas) = alphas { + alpha_fill::alpha_composite( + simd, + dest, + src_iter, + cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite(simd, dest, src_iter); + } + } + + /// Applies a blend mode to composite source pixels onto destination. + /// + /// Dispatches to either the masked or unmasked blend implementation. + /// Handles both color mixing (multiply, screen, etc.) and compositing. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + mut start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let alpha_iter = alphas.map(|a| cast_slice::(a).iter().copied()); + + let mask_iter = mask.map(|m| { + iter::from_fn(|| { + let sample = |x: u16, y: u16| { + if x < m.width() && y < m.height() { + m.sample(x, y) + } else { + 255 + } + }; + + let samples = [ + sample(start_x, start_y), + sample(start_x, start_y + 1), + sample(start_x, start_y + 2), + sample(start_x, start_y + 3), + sample(start_x + 1, start_y), + sample(start_x + 1, start_y + 1), + sample(start_x + 1, start_y + 2), + sample(start_x + 1, start_y + 3), + ]; + + start_x += 2; + + Some(samples) + }) + }); + + match (alpha_iter, mask_iter) { + (Some(alpha_iter), Some(mut mask_iter)) => { + let iter = alpha_iter.map(|a1| { + let a2 = mask_iter.next().unwrap(); + [ + div_255(a1[0] as u16 * a2[0] as u16) as u8, + div_255(a1[1] as u16 * a2[1] as u16) as u8, + div_255(a1[2] as u16 * a2[2] as u16) as u8, + div_255(a1[3] as u16 * a2[3] as u16) as u8, + div_255(a1[4] as u16 * a2[4] as u16) as u8, + div_255(a1[5] as u16 * a2[5] as u16) as u8, + div_255(a1[6] as u16 * a2[6] as u16) as u8, + div_255(a1[7] as u16 * a2[7] as u16) as u8, + ] + }); + alpha_fill::blend(simd, dest, src, blend_mode, iter); + } + (None, Some(mask_iter)) => alpha_fill::blend(simd, dest, src, blend_mode, mask_iter), + (Some(alpha_iter), None) => alpha_fill::blend(simd, dest, src, blend_mode, alpha_iter), + (None, None) => { + fill::blend(simd, dest, src, blend_mode); + } + } + } +} + +mod fill { + //! Alpha compositing and blending operations without per-pixel alpha masks. + //! + //! This module handles the case where we're compositing full opacity pixels, + //! using only the source alpha channel for compositing. + + use crate::fine::Splat4thExt; + use crate::fine::lowp::compose::ComposeExt; + use crate::fine::lowp::mix; + use crate::peniko::{BlendMode, Mix}; + use vello_common::fearless_simd::*; + use vello_common::util::normalized_mul_u8x32; + + /// Applies blend mode compositing to a buffer without per-pixel masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [u8], + src: T, + blend_mode: BlendMode, + ) { + simd.vectorize( + #[inline(always)] + || { + let default_mix = matches!(blend_mode.mix, Mix::Normal); + for (next_dest, next_src) in dest.chunks_exact_mut(32).zip(src) { + let bg_v = u8x32::from_slice(simd, next_dest); + let src_v = if default_mix { + next_src + } else { + mix(next_src, bg_v, blend_mode) + }; + let res = blend_mode.compose(simd, src_v, bg_v, None); + res.store_slice(next_dest); + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Uses the "over" operator: `result = src + bg * (1 - src_alpha)` + pub(super) fn alpha_composite_solid(s: S, dest: &mut [u8], src: [u8; 4]) { + s.vectorize( + #[inline(always)] + || { + let one_minus_alpha = 255 - u8x32::splat(s, src[3]); + let src_c = u32x8::splat(s, u32::from_ne_bytes(src)).to_bytes(); + + for next_dest in dest.chunks_exact_mut(64) { + // We process in batches of 64 because loading/storing is much faster this way (at least on NEON), + // but since we widen to u16, we can only work with 256 bits, so we split it up. + let bg_v = u8x64::from_slice(s, next_dest); + let (bg_1, bg_2) = s.split_u8x64(bg_v); + let res_1 = alpha_composite_inner(s, bg_1, src_c, one_minus_alpha); + let res_2 = alpha_composite_inner(s, bg_2, src_c, one_minus_alpha); + let combined = s.combine_u8x32(res_1, res_2); + combined.store_slice(next_dest); + } + }, + ); + } + + /// Composites a buffer of colors onto another buffer using alpha blending. + /// + /// Each source pixel is composited individually based on its alpha channel. + pub(super) fn alpha_composite>>( + simd: S, + dest: &mut [u8], + src: T, + ) { + simd.vectorize( + #[inline(always)] + || { + for (next_dest, next_src) in dest.chunks_exact_mut(32).zip(src) { + let one_minus_alpha = 255 - next_src.splat_4th(); + let bg_v = u8x32::from_slice(simd, next_dest); + let res = alpha_composite_inner(simd, bg_v, next_src, one_minus_alpha); + res.store_slice(next_dest); + } + }, + ); + } + + /// Performs the core alpha compositing calculation. + /// + /// Formula: `result = src + bg * (1 - src_alpha)` + /// This implements the Porter-Duff "source over" operator. + #[inline(always)] + fn alpha_composite_inner( + s: S, + bg: u8x32, + src: u8x32, + one_minus_alpha: u8x32, + ) -> u8x32 { + s.narrow_u16x32(normalized_mul_u8x32(bg, one_minus_alpha)) + src + } +} + +mod alpha_fill { + //! Alpha compositing and blending operations with per-pixel alpha masks. + //! + //! This module handles compositing when each pixel has an additional mask value + //! (e.g., from anti-aliasing or clip masks) that modulates the source alpha. + + use crate::fine::Splat4thExt; + use crate::fine::lowp::compose::ComposeExt; + use crate::fine::lowp::{extract_masks, mix}; + use crate::peniko::{BlendMode, Mix}; + use vello_common::fearless_simd::*; + use vello_common::util::{Div255Ext, normalized_mul_u8x32}; + + /// Applies blend mode compositing with per-pixel alpha masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [u8], + src: T, + blend_mode: BlendMode, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let default_mix = matches!(blend_mode.mix, Mix::Normal); + + for ((next_bg, next_mask), next_src) in + dest.chunks_exact_mut(32).zip(alphas).zip(src) + { + let bg_v = u8x32::from_slice(simd, next_bg); + let src_c = if default_mix { + next_src + } else { + mix(next_src, bg_v, blend_mode) + }; + let masks = extract_masks(simd, &next_mask); + let res = blend_mode.compose(simd, src_c, bg_v, Some(masks)); + + res.store_slice(next_bg); + } + }, + ); + } + + /// Composites a solid color with per-pixel alpha masks. + /// + /// Combines source alpha with mask values: `effective_alpha = src_alpha * mask / 255` + #[inline(always)] + pub(super) fn alpha_composite_solid( + s: S, + dest: &mut [u8], + src: [u8; 4], + alphas: impl Iterator, + ) { + s.vectorize( + #[inline(always)] + || { + let src_a = u8x32::splat(s, src[3]); + let src_c = u32x8::splat(s, u32::from_ne_bytes(src)).to_bytes(); + let one = u8x32::splat(s, 255); + + for (next_bg, next_mask) in dest.chunks_exact_mut(32).zip(alphas) { + alpha_composite_inner(s, next_bg, &next_mask, src_c, src_a, one); + } + }, + ); + } + + /// Composites a buffer of colors with per-pixel alpha masks. + /// + /// Each pixel's source alpha is modulated by its corresponding mask value. + #[inline(always)] + pub(super) fn alpha_composite>>( + simd: S, + dest: &mut [u8], + src: T, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let one = u8x32::splat(simd, 255); + + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(32).zip(alphas).zip(src) + { + let src_a = next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, &next_mask, next_src, src_a, one); + } + }, + ); + } + + /// Performs alpha compositing with mask modulation. + /// + /// Formula: `result = src * mask + bg * (1 - src_alpha * mask)` + /// The mask value modulates both the source contribution and the inverse alpha. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [u8], + masks: &[u8; 8], + src_c: u8x32, + src_a: u8x32, + one: u8x32, + ) { + s.vectorize( + #[inline(always)] + || { + let bg_v = u8x32::from_slice(s, dest); + + let mask_v = extract_masks(s, masks); + let inv_src_a_mask_a = one - s.narrow_u16x32(normalized_mul_u8x32(src_a, mask_v)); + + let p1 = s.widen_u8x32(bg_v) * s.widen_u8x32(inv_src_a_mask_a); + let p2 = s.widen_u8x32(src_c) * s.widen_u8x32(mask_v); + let res = s.narrow_u16x32((p1 + p2).div_255()); + + res.store_slice(dest); + }, + ); + } +} + +/// Applies blend mode mixing by converting to f32, mixing, then converting back to u8. +/// +/// TODO: Add a proper lowp mix pipeline that operates entirely in integer space +/// for better performance (currently converts through f32 which is slower). +fn mix(src_c: u8x32, bg_c: u8x32, blend_mode: BlendMode) -> u8x32 { + let to_f32 = |val: u8x32| { + let (a, b) = src_c.simd.split_u8x32(val); + let mut a = u8_to_f32(a); + let mut b = u8_to_f32(b); + a *= f32x16::splat(src_c.simd, 1.0 / 255.0); + b *= f32x16::splat(src_c.simd, 1.0 / 255.0); + (a, b) + }; + + let to_u8 = |val1: f32x16, val2: f32x16| { + let val1 = + f32_to_u8(f32x16::splat(val1.simd, 255.0).mul_add(val1, f32x16::splat(val1.simd, 0.5))); + let val2 = + f32_to_u8(f32x16::splat(val2.simd, 255.0).mul_add(val2, f32x16::splat(val2.simd, 0.5))); + + val1.simd.combine_u8x16(val1, val2) + }; + + let (mut src_1, mut src_2) = to_f32(src_c); + let (bg_1, bg_2) = to_f32(bg_c); + + src_1 = highp::blend::mix(src_1, bg_1, blend_mode); + src_2 = highp::blend::mix(src_2, bg_2, blend_mode); + + to_u8(src_1, src_2) +} + +/// Expands 8 mask bytes into a 32-byte SIMD vector where each pixel's 4 components +/// share the same mask value (each of 8 mask values is repeated 4 times). +/// +/// Input: [m0, m1, m2, m3, m4, m5, m6, m7] +/// Output: [m0, m0, m0, m0, m1, m1, m1, m1, ..., m7, m7, m7, m7] +#[inline(always)] +fn extract_masks(simd: S, masks: &[u8; 8]) -> u8x32 { + let m1 = u32x4::splat(simd, u32::from_ne_bytes(masks[0..4].try_into().unwrap())).to_bytes(); + let m2 = u32x4::splat(simd, u32::from_ne_bytes(masks[4..8].try_into().unwrap())).to_bytes(); + + let zipped1 = m1.zip_low(m1); + let zipped1 = zipped1.zip_low(zipped1); + + let zipped2 = m2.zip_low(m2); + let zipped2 = zipped2.zip_low(zipped2); + + simd.combine_u8x16(zipped1, zipped2) +} + +/// Copies color data from the scratch buffer to the output region (scalar fallback). +/// +/// The scratch buffer stores pixels in column-major order for SIMD efficiency, +/// while the region uses row-major order for output. +#[inline(always)] +fn pack(region: &mut Region<'_>, blend_buf: &[u8]) { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region + .row_mut(y) + .chunks_exact_mut(COLOR_COMPONENTS) + .enumerate() + { + let idx = COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + pixel.copy_from_slice(&blend_buf[idx..][..COLOR_COMPONENTS]); + } + } +} + +/// Copies color data from the output region to the scratch buffer. +/// +/// Converts from row-major (region) to column-major (scratch buffer) layout. +/// This is the inverse operation of `pack`. +#[inline(always)] +fn unpack(region: &mut Region<'_>, blend_buf: &mut [u8]) { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region.row_mut(y).chunks_exact(COLOR_COMPONENTS).enumerate() { + let idx = COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + blend_buf[idx..][..COLOR_COMPONENTS].copy_from_slice(pixel); + } + } +} + +/// SIMD-optimized version of `pack` for full-size tiles using interleaved loads. +/// +/// Uses `load_interleaved_128` to efficiently transpose and copy data from column-major +/// scratch buffer to row-major output region. Performance characteristics are highly +/// architecture-dependent: +/// - On NEON: ~3x faster than scalar `pack` +/// - On fallback SIMD: ~3x slower than scalar `pack` +/// +/// TODO: Consider runtime detection to fall back to scalar on non-NEON architectures. +#[inline(always)] +fn pack_block(simd: S, region: &mut Region<'_>, mut buf: &[u8]) { + buf = &buf[..SCRATCH_BUF_SIZE]; + + const CHUNK_LENGTH: usize = 64; + const SLICE_WIDTH: usize = WideTile::WIDTH as usize * COLOR_COMPONENTS; + + let region_areas = region.areas(); + let [s1, s2, s3, s4] = region_areas; + let dest_slices: &mut [&mut [u8; SLICE_WIDTH]; 4] = &mut [ + (*s1).try_into().unwrap(), + (*s2).try_into().unwrap(), + (*s3).try_into().unwrap(), + (*s4).try_into().unwrap(), + ]; + + for (idx, col) in buf.chunks_exact(CHUNK_LENGTH).enumerate() { + let dest_idx = idx * CHUNK_LENGTH / 4; + + let casted: &[u32; 16] = cast_slice::(col).try_into().unwrap(); + + let loaded = simd.load_interleaved_128_u32x16(casted).to_bytes(); + let (loaded_lo, loaded_hi) = simd.split_u8x64(loaded); + let (loaded_1, loaded_2) = simd.split_u8x32(loaded_lo); + let (loaded_3, loaded_4) = simd.split_u8x32(loaded_hi); + loaded_1.store_slice(&mut dest_slices[0][dest_idx..][..16]); + loaded_2.store_slice(&mut dest_slices[1][dest_idx..][..16]); + loaded_3.store_slice(&mut dest_slices[2][dest_idx..][..16]); + loaded_4.store_slice(&mut dest_slices[3][dest_idx..][..16]); + } +} + +/// The pendant to `pack_block`, but for unpacking. +/// +/// See the [`unpack`] method for more information. +#[inline(always)] +fn unpack_block(simd: S, region: &mut Region<'_>, buf: &mut [u8]) { + let buf: &mut [f32] = bytemuck::cast_slice_mut(&mut buf[..SCRATCH_BUF_SIZE]); + const CHUNK_LENGTH: usize = 16; + + let region_areas = region.areas(); + let [s1, s2, s3, s4] = region_areas; + + for (idx, col) in buf.as_chunks_mut::().0.iter_mut().enumerate() { + let src_idx = idx * CHUNK_LENGTH; + + // Note: We experimented with using u32 vs. f32 for this, but it seems like for some reason + // f32 works better on M1, while on M4 they are the same. Probably worth doing more + // benchmarks on different systems. + let r0 = f32x4::from_bytes(u8x16::from_slice(simd, &s1[src_idx..][..16])); + let r1 = f32x4::from_bytes(u8x16::from_slice(simd, &s2[src_idx..][..16])); + let r2 = f32x4::from_bytes(u8x16::from_slice(simd, &s3[src_idx..][..16])); + let r3 = f32x4::from_bytes(u8x16::from_slice(simd, &s4[src_idx..][..16])); + + let combined = simd.combine_f32x8(simd.combine_f32x4(r0, r1), simd.combine_f32x4(r2, r3)); + + simd.store_interleaved_128_f32x16(combined, col); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + use vello_common::fearless_simd::dispatch; + + fn test_pack_unpack_roundtrip( + pack_fn: impl FnOnce(&mut Region<'_>, &[u8]), + unpack_fn: impl FnOnce(&mut Region<'_>, &mut [u8]), + ) { + let width = WideTile::WIDTH; + let height = Tile::HEIGHT; + + // Just some pseudo-random numbers. + let blend_buf = (0..SCRATCH_BUF_SIZE) + .map(|n| ((n * 7 + 13) % 256) as u8) + .collect::>(); + + let mut region_data = vec![0_u8; width as usize * height as usize * COLOR_COMPONENTS]; + let row_len = width as usize * COLOR_COMPONENTS; + let (r0, rest) = region_data.split_at_mut(row_len); + let (r1, rest) = rest.split_at_mut(row_len); + let (r2, r3) = rest.split_at_mut(row_len); + let mut region = Region::new([r0, r1, r2, r3], 0, 0, width, height); + + // First pack. + pack_fn(&mut region, &blend_buf); + + // Now reverse the process, unpacking into a new buffer. + let mut unpacked_buf = vec![0_u8; SCRATCH_BUF_SIZE]; + unpack_fn(&mut region, &mut unpacked_buf); + + assert_eq!(&blend_buf, &unpacked_buf); + } + + #[test] + fn pack_unpack_roundtrip() { + test_pack_unpack_roundtrip(pack, unpack); + } + + #[test] + fn pack_block_unpack_block_roundtrip() { + dispatch!(Level::try_detect().unwrap_or(Level::baseline()), simd => { + test_pack_unpack_roundtrip( + |region, buf| simd.vectorize(|| pack_block(simd, region, buf)), + |region, buf| simd.vectorize(|| unpack_block(simd, region, buf)), + ); + }); + } +} diff --git a/vendor/vello_cpu/src/fine/mod.rs b/vendor/vello_cpu/src/fine/mod.rs new file mode 100644 index 0000000000..c16c8282df --- /dev/null +++ b/vendor/vello_cpu/src/fine/mod.rs @@ -0,0 +1,1127 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Fine rasterization stage of the rendering pipeline. +//! +//! This module implements the fine rasterization phase, which processes tiles at the pixel level. +//! It supports both high-precision (f32) and low-precision (u8) rendering paths, along with +//! various paint types including solid colors, gradients, images, and blurred rounded rectangles. + +mod common; +mod highp; +mod lowp; + +use crate::fine::common::gradient::linear::SimdLinearKind; +use crate::fine::common::gradient::radial::SimdRadialKind; +use crate::fine::common::gradient::sweep::SimdSweepKind; +use crate::fine::common::gradient::{GradientPainter, calculate_t_vals}; +use crate::fine::common::image::{FilteredImagePainter, NNImagePainter, PlainNNImagePainter}; +use crate::fine::common::rounded_blurred_rect::BlurredRoundedRectFiller; +use crate::layer_manager::LayerManager; +use crate::peniko::{BlendMode, ImageQuality}; +use crate::region::Region; +use crate::util::EncodedImageExt; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt::Debug; +use core::iter; +use vello_common::coarse::{Cmd, CommandAttrs, WideTile}; +use vello_common::encode::{ + EncodedBlurredRoundedRectangle, EncodedGradient, EncodedImage, EncodedKind, EncodedPaint, +}; +use vello_common::fearless_simd::{ + Bytes, Simd, SimdBase, SimdFloat, SimdInt, SimdInto, f32x4, f32x8, f32x16, u8x16, u8x32, u32x4, + u32x8, +}; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, ImageSource, Paint, PremulColor, Tint}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::Splat4thExt; +use vello_common::tile::Tile; +use vello_common::util::f32_to_u8; + +pub use highp::F32Kernel; +pub use lowp::U8Kernel; + +/// Offset to shift from pixel corner to pixel center for sampling. +const PIXEL_CENTER_OFFSET: f64 = 0.5; + +/// Number of color components per pixel (RGBA). +pub(crate) const COLOR_COMPONENTS: usize = 4; + +/// Number of color components in a single column of a tile (height * components). +pub(crate) const TILE_HEIGHT_COMPONENTS: usize = Tile::HEIGHT as usize * COLOR_COMPONENTS; + +/// Size of the scratch buffer used for intermediate rendering operations. +/// Sized to hold a full wide tile with all color components. +pub const SCRATCH_BUF_SIZE: usize = + WideTile::WIDTH as usize * Tile::HEIGHT as usize * COLOR_COMPONENTS; + +/// Type alias for a scratch buffer that can hold a full wide tile's worth of data. +pub type ScratchBuf = [F; SCRATCH_BUF_SIZE]; + +/// Trait for numeric types used in fine rasterization. +/// +/// This trait abstracts over `f32` and `u8` to allow the same rendering logic +/// to work with both high-precision (floating-point) and low-precision (integer) +/// representations. This enables performance optimizations while maintaining accuracy +/// where needed. +pub trait Numeric: Copy + Default + Clone + Debug + PartialEq + Send + Sync + 'static { + /// The zero value for this numeric type (0.0 for f32, 0 for u8). + const ZERO: Self; + + /// The maximum opacity value for this numeric type (1.0 for f32, 255 for u8). + const ONE: Self; +} + +impl Numeric for f32 { + const ZERO: Self = 0.0; + const ONE: Self = 1.0; +} + +impl Numeric for u8 { + const ZERO: Self = 0; + const ONE: Self = 255; +} + +/// Trait for SIMD vector types that can convert between f32 and u8 representations. +/// +/// This trait enables efficient batch conversions between different numeric representations +/// during rendering operations, supporting both high-precision and low-precision rendering paths. +pub trait NumericVec: Copy + Clone + Send + Sync { + /// Convert from a SIMD vector of f32 values to this type. + fn from_f32(simd: S, val: f32x16) -> Self; + + /// Convert from a SIMD vector of u8 values to this type. + fn from_u8(simd: S, val: u8x16) -> Self; +} + +impl NumericVec for f32x16 { + #[inline(always)] + fn from_f32(_: S, val: Self) -> Self { + val + } + + #[inline(always)] + fn from_u8(simd: S, val: u8x16) -> Self { + let converted = u8_to_f32(val); + converted * Self::splat(simd, 1.0 / 255.0) + } +} + +impl NumericVec for u8x16 { + #[inline(always)] + fn from_f32(simd: S, val: f32x16) -> Self { + let v1 = f32x16::splat(simd, 255.0); + let v2 = f32x16::splat(simd, 0.5); + let mulled = val.mul_add(v1, v2); + + f32_to_u8(mulled) + } + + #[inline(always)] + fn from_u8(_: S, val: Self) -> Self { + val + } +} + +/// Convert a SIMD vector of u8 values to f32 values. +/// +/// This function efficiently converts 16 u8 values to their f32 equivalents using SIMD operations, +/// preserving the values without normalization (i.e., 255 becomes 255.0, not 1.0). +#[inline(always)] +pub(crate) fn u8_to_f32(val: u8x16) -> f32x16 { + let simd = val.simd; + let zeroes = u8x16::splat(simd, 0); + + let zip1 = simd.zip_high_u8x16(val, zeroes); + let zip2 = simd.zip_low_u8x16(val, zeroes); + + let p1 = simd + .zip_low_u8x16(zip2, zeroes) + .bitcast::>() + .to_float::>(); + let p2 = simd + .zip_high_u8x16(zip2, zeroes) + .bitcast::>() + .to_float::>(); + let p3 = simd + .zip_low_u8x16(zip1, zeroes) + .bitcast::>() + .to_float::>(); + let p4 = simd + .zip_high_u8x16(zip1, zeroes) + .bitcast::>() + .to_float::>(); + + simd.combine_f32x8(simd.combine_f32x4(p1, p2), simd.combine_f32x4(p3, p4)) +} + +/// Trait for SIMD vector types used in compositing and blending operations. +/// +/// This trait abstracts over different SIMD vector widths (f32x16 for high-precision, +/// u8x32 for low-precision) to enable efficient batch processing of pixel data during +/// blending and compositing. +pub trait CompositeType: Copy + Clone + Send + Sync { + /// The number of numeric values this composite type can hold. + const LENGTH: usize; + + /// Load values from a slice into this composite type. + fn from_slice(simd: S, slice: &[N]) -> Self; + + /// Create a composite type by repeating a single RGBA color across all elements. + fn from_color(simd: S, color: [N; 4]) -> Self; +} + +impl CompositeType for f32x16 { + const LENGTH: usize = 16; + + #[inline(always)] + fn from_slice(simd: S, slice: &[f32]) -> Self { + >::from_slice(simd, slice) + } + + #[inline(always)] + fn from_color(simd: S, color: [f32; 4]) -> Self { + Self::block_splat(f32x4::from_slice(simd, &color[..])) + } +} + +impl CompositeType for u8x32 { + const LENGTH: usize = 32; + + #[inline(always)] + fn from_slice(simd: S, slice: &[u8]) -> Self { + >::from_slice(simd, slice) + } + + #[inline(always)] + fn from_color(simd: S, color: [u8; 4]) -> Self { + u32x8::block_splat(u32x4::splat(simd, u32::from_ne_bytes(color))).to_bytes() + } +} + +/// A kernel for performing fine rasterization. +/// +/// This trait defines the interface for tile-level rendering operations, abstracting over +/// different numeric precisions (f32 vs u8). Implementations provide the low-level pixel +/// manipulation, blending, and painting operations needed to render tiles. +/// +/// The two main implementations are: +/// - [`F32Kernel`]: High-precision rendering using 32-bit floating-point values +/// - [`U8Kernel`]: Low-precision rendering using 8-bit integer values +pub trait FineKernel: Send + Sync + 'static { + /// The basic underlying numerical type of the kernel (f32 or u8). + type Numeric: Numeric; + + /// The SIMD composite type used for efficient batch blending and compositing operations. + type Composite: CompositeType; + + /// The SIMD vector type used for conversions between u8 and f32 representations. + type NumericVec: NumericVec; + + /// Extract and convert a premultiplied color to the kernel's numeric type. + /// + /// Converts RGBA components from the standard premultiplied color format to + /// the kernel's internal representation (e.g., 0.0-1.0 for f32, 0-255 for u8). + fn extract_color(color: PremulColor) -> [Self::Numeric; 4]; + + /// Pack the blend buffer contents into the output region. + /// + /// Converts from the internal scratch buffer format to the output tile format, + /// writing the results to the provided region. + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]); + + /// Unpack the region contents back into the blend buffer. + /// + /// Performs the reverse of `pack`, reading pixel data from the tile region + /// and loading it into the scratch buffer for further processing. + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]); + + /// Apply a filter to a layer. + /// + /// This is used for applying filters to whole layers, which is necessary for + /// spatial filters (like blur) that need to access neighboring pixels. The filter + /// is applied in-place to the provided pixmap. + /// + /// The transform parameter is used to scale filter parameters based on the current + /// transformation matrix (e.g., zoom level), ensuring filters look consistent + /// regardless of scale. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ); + + /// Fill the target buffer with a solid color. + /// + /// Efficiently replicates the given RGBA color across all pixels in the target buffer. + fn copy_solid(simd: S, target: &mut [Self::Numeric], color: [Self::Numeric; 4]); + /// Create a painter for rendering gradients. + /// + /// Returns a painter that can render linear, radial, or sweep gradients based on + /// pre-computed t values (gradient interpolation parameters). + fn gradient_painter<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || GradientPainter::new(simd, gradient, t_vals), + ) + } + + /// Create a painter for rendering gradients with undefined region support. + /// + /// Similar to `gradient_painter`, but with support for masking undefined locations + /// (used for radial gradients that may have mathematically undefined regions). + /// + /// This is intentionally a duplicate of the default [`FineKernel::gradient_painter`] + /// implementation--the `U8Kernel` overrides that method, but not this one. + fn gradient_painter_with_undefined<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || GradientPainter::new(simd, gradient, t_vals), + ) + } + /// Create a painter for rendering axis-aligned nearest-neighbor images. + /// + /// Optimized painter for images with `Low` quality and no skewing component in their + /// transform. This is the fastest image rendering path. + fn plain_nn_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || PlainNNImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering nearest-neighbor images with transforms. + /// + /// Similar to `plain_nn_image_painter`, but supports arbitrary affine transforms + /// including skewing and rotation. + fn nn_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || NNImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering images with `Medium` quality filtering. + /// + /// Uses bilinear filtering for smoother appearance than nearest-neighbor. + fn medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering axis-aligned images with `Medium` quality filtering. + /// + /// Optimized painter for images with bilinear filtering and no skewing component. + fn plain_medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering images with `High` quality filtering. + /// + /// Uses high-quality filtering for the best visual appearance. + fn high_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering blurred rounded rectangles. + /// + /// Efficiently renders rounded rectangles with gaussian blur applied, + /// computing the blur analytically rather than as a post-process. + fn blurred_rounded_rectangle_painter( + simd: S, + rect: &EncodedBlurredRoundedRectangle, + start_x: f64, + start_y: f64, + ) -> impl Painter { + simd.vectorize( + #[inline(always)] + || BlurredRoundedRectFiller::new(simd, rect, start_x, start_y), + ) + } + /// Apply a mask to the destination buffer. + /// + /// Multiplies each pixel in the destination by the corresponding mask value, + /// effectively masking out or reducing the opacity of pixels. + fn apply_mask(simd: S, dest: &mut [Self::Numeric], src: impl Iterator); + + /// Apply a painter to render content into the destination buffer. + /// + /// Invokes the painter to generate pixel values and writes them to the destination. + fn apply_painter<'a>(simd: S, dest: &mut [Self::Numeric], painter: impl Painter + 'a); + + /// Apply an image tint to an already-painted buffer. + /// + /// This is called as a post-pass after `apply_painter`, only when a tint is + /// present. Keeping tint application out of the per-pixel iterator avoids + /// regressing the non-tinted fast path. + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint); + + /// Perform alpha compositing with a solid color over the target buffer. + /// + /// Blends a solid RGBA color over the existing contents using standard alpha compositing + /// (Porter-Duff source-over). Optionally applies additional per-pixel alpha values. + fn alpha_composite_solid( + simd: S, + target: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ); + + /// Perform alpha compositing with a source buffer over the destination buffer. + /// + /// Blends the source buffer contents over the destination using standard alpha compositing. + /// Optionally applies additional per-pixel alpha values. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ); + + /// Blend the source into the destination with a specified blend mode. + /// + /// Applies advanced blending operations (e.g., multiply, screen, overlay) as specified + /// by the blend mode. Optionally applies additional per-pixel alpha values. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ); +} + +/// Fine rasterizer for processing tiles at the pixel level. +/// +/// This structure maintains the state and scratch buffers needed for tile-based rendering. +/// It processes rendering commands and manages a stack of blend buffers for layer composition. +#[derive(Debug)] +pub struct Fine> { + /// The (x, y) coordinates of the currently active wide tile being rendered. + pub(crate) wide_coords: (u16, u16), + + /// Stack of blend buffers for managing layers and composition. + /// + /// Each layer pushes a new buffer onto this stack, and layers are composited + /// by popping and blending with the buffer below. + pub(crate) blend_buf: Vec>, + + /// Intermediate buffer used by painters to store generated pixel data before compositing. + pub(crate) paint_buf: ScratchBuf, + + /// Buffer for storing gradient interpolation parameters (t values). + /// + /// Gradients pre-compute these values for efficiency before color lookup. + pub(crate) f32_buf: Vec, + + /// The SIMD context used for vectorized operations. + pub(crate) simd: S, +} + +impl> Fine { + /// Create a new fine rasterizer with the given SIMD context. + /// + /// Initializes all scratch buffers and sets up the initial blend buffer. + pub fn new(simd: S) -> Self { + Self { + simd, + wide_coords: (0, 0), + blend_buf: vec![[T::Numeric::ZERO; SCRATCH_BUF_SIZE]], + f32_buf: vec![0.0; SCRATCH_BUF_SIZE / 4], + paint_buf: [T::Numeric::ZERO; SCRATCH_BUF_SIZE], + } + } + + /// Set the coordinates of the wide tile currently being rendered. + /// + /// This is used by painters and other operations to compute absolute pixel positions. + pub fn set_coords(&mut self, x: u16, y: u16) { + self.wide_coords = (x, y); + } + + /// Clear the current blend buffer to a solid color. + /// + /// This efficiently fills the entire buffer with the given premultiplied color. + pub fn clear(&mut self, premul_color: PremulColor) { + let converted_color = T::extract_color(premul_color); + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::copy_solid(self.simd, blend_buf, converted_color); + } + + /// Writes the current blend buffer contents to the output region. + /// + /// This copies pixel data from the internal scratch buffer to the tile region, + /// converting the layout from the internal representation to the output format. + pub fn pack(&self, region: &mut Region<'_>) { + let blend_buf = self.blend_buf.last().unwrap(); + + T::pack(self.simd, region, blend_buf); + } + + /// Reads the region contents back into the blend buffer. + /// + /// This copies pixel data from the tile region to the internal scratch buffer, + /// performing the reverse operation of `pack`. This is typically used when a layer + /// needs to be read back for further processing. + pub fn unpack(&mut self, region: &mut Region<'_>) { + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::unpack(self.simd, region, blend_buf); + } + + /// Apply a filter to a layer. + /// + /// This applies the filter using the kernel's implementation, mutating the layer. + pub fn filter_layer( + &self, + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + T::filter_layer(pixmap, filter, layer_manager, transform); + } + + /// Execute a rendering command on the current tile. + /// + /// This is the main dispatch method that processes different command types including + /// fills, clips, blends, filters, masks, and buffer operations. + pub(crate) fn run_cmd( + &mut self, + cmd: &Cmd, + alphas: &[u8], + paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + attrs: &CommandAttrs, + ) { + match cmd { + Cmd::Fill(f) => { + let fill_attrs = &attrs.fill[f.attrs_idx as usize]; + self.fill( + usize::from(f.x), + usize::from(f.width), + &fill_attrs.paint, + fill_attrs.blend_mode, + paints, + image_resolver, + None, + fill_attrs.mask.as_ref(), + ); + } + Cmd::AlphaFill(s) => { + let fill_attrs = &attrs.fill[s.attrs_idx as usize]; + let alpha_idx = fill_attrs.alpha_idx(s.alpha_offset) as usize; + self.fill( + usize::from(s.x), + usize::from(s.width), + &fill_attrs.paint, + fill_attrs.blend_mode, + paints, + image_resolver, + Some(&alphas[alpha_idx..]), + fill_attrs.mask.as_ref(), + ); + } + Cmd::Filter(_filter, _) => { + // TODO: Apply non-spatial filters here; spatial filters need layer-level processing + // + // Spatial filters (e.g., Gaussian blur) need neighboring pixels and must be + // rendered to a pixmap for layer-level processing. Non-spatial effects (e.g., + // color matrix, component transfer) can be processed here directly on the + // blend buffer per-pixel as wide commands. + } + Cmd::PushBuf(_layer_kind, _) => { + self.blend_buf.push([T::Numeric::ZERO; SCRATCH_BUF_SIZE]); + } + Cmd::PopBuf => { + self.blend_buf.pop(); + } + Cmd::ClipFill(cf) => { + self.clip(cf.x as usize, cf.width as usize, None); + } + Cmd::ClipStrip(cs) => { + let clip_attrs = &attrs.clip[cs.attrs_idx as usize]; + let alpha_idx = clip_attrs.alpha_idx(cs.alpha_offset) as usize; + self.clip(cs.x as usize, cs.width as usize, Some(&alphas[alpha_idx..])); + } + Cmd::Blend(b) => self.blend(*b), + Cmd::Mask(m) => { + let start_x = self.wide_coords.0 * WideTile::WIDTH; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + let blend_buf = self.blend_buf.last_mut().unwrap(); + + let width = (blend_buf.len() / (Tile::HEIGHT as usize * COLOR_COMPONENTS)) as u16; + let y = start_y as u32 + u32x4::from_slice(self.simd, &[0, 1, 2, 3]); + + let iter = (start_x..(start_x + width)).map(|x| { + let x_in_range = x < m.width(); + + macro_rules! sample { + ($idx:expr) => { + if x_in_range && (y[$idx] as u16) < m.height() { + m.sample(x, y[$idx] as u16) + } else { + 0 + } + }; + } + + let s1 = sample!(0); + let s2 = sample!(1); + let s3 = sample!(2); + let s4 = sample!(3); + + let samples = u8x16::from_slice( + self.simd, + &[ + s1, s1, s1, s1, s2, s2, s2, s2, s3, s3, s3, s3, s4, s4, s4, s4, + ], + ); + T::NumericVec::from_u8(self.simd, samples) + }); + + T::apply_mask(self.simd, blend_buf, iter); + } + Cmd::Opacity(o) => { + if *o != 1.0 { + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::apply_mask( + self.simd, + blend_buf, + iter::repeat(T::NumericVec::from_f32( + self.simd, + f32x16::splat(self.simd, *o), + )), + ); + } + } + Cmd::PushZeroClip(_) | Cmd::PopZeroClip => { + // These commands are handled by the dispatcher and should not reach fine rasterization + unreachable!(); + } + Cmd::BatchEnd => {} + } + } + + /// Fill a horizontal strip within the current tile using the given paint. + /// + /// This is the core painting method that handles solid colors, gradients, images, + /// and blurred rounded rectangles. It applies the paint starting at the given x + /// coordinate with the specified width, using the provided blend mode. + /// + /// Note: For short strip segments, benchmarks showed that not inlining this method + /// leads to significantly worse performance. + pub fn fill( + &mut self, + x: usize, + width: usize, + fill: &Paint, + blend_mode: BlendMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let blend_buf = &mut self.blend_buf.last_mut().unwrap()[x * TILE_HEIGHT_COMPONENTS..] + [..TILE_HEIGHT_COMPONENTS * width]; + let default_blend = blend_mode == BlendMode::default(); + + match fill { + Paint::Solid(color) => { + let color = T::extract_color(*color); + + // If color is completely opaque, we can just directly override + // the blend buffer. + if color[3] == T::Numeric::ONE + && default_blend + && alphas.is_none() + && mask.is_none() + { + T::copy_solid(self.simd, blend_buf, color); + + return; + } + + if default_blend && mask.is_none() { + T::alpha_composite_solid(self.simd, blend_buf, color, alphas); + } else { + let start_x = self.wide_coords.0 * WideTile::WIDTH + x as u16; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + T::blend( + self.simd, + blend_buf, + start_x, + start_y, + iter::repeat(T::Composite::from_color(self.simd, color)), + blend_mode, + alphas, + mask, + ); + } + } + Paint::Indexed(paint) => { + let color_buf = &mut self.paint_buf[x * TILE_HEIGHT_COMPONENTS..] + [..TILE_HEIGHT_COMPONENTS * width]; + + let encoded_paint = &encoded_paints[paint.index()]; + + let start_x = self.wide_coords.0 * WideTile::WIDTH + x as u16; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + // Make sure sampling happens at the center of the pixel. + let sampler_x = start_x as f64 + PIXEL_CENTER_OFFSET; + let sampler_y = start_y as f64 + PIXEL_CENTER_OFFSET; + + // We need to have this as a macro because closures cannot take generic arguments, and + // we would have to repeatedly provide all arguments if we made it a function. + macro_rules! fill_complex_paint { + ($may_have_transparency:expr, $filler:expr) => { + fill_complex_paint!($may_have_transparency, $filler, None::<&Tint>) + }; + ($may_have_transparency:expr, $filler:expr, $tint:expr) => { + if $may_have_transparency || alphas.is_some() { + T::apply_painter(self.simd, color_buf, $filler); + if let Some(t) = $tint { + T::apply_tint(self.simd, color_buf, t); + } + + if default_blend && mask.is_none() { + T::alpha_composite_buffer(self.simd, blend_buf, color_buf, alphas); + } else { + T::blend( + self.simd, + blend_buf, + start_x, + start_y, + color_buf + .chunks_exact(T::Composite::LENGTH) + .map(|s| T::Composite::from_slice(self.simd, s)), + blend_mode, + alphas, + mask, + ); + } + } else { + // Similarly to solid colors we can just override the previous values + // if all colors in the gradient are fully opaque. + T::apply_painter(self.simd, blend_buf, $filler); + if let Some(t) = $tint { + T::apply_tint(self.simd, blend_buf, t); + } + } + }; + } + + match encoded_paint { + EncodedPaint::BlurredRoundedRect(b) => { + fill_complex_paint!( + true, + T::blurred_rounded_rectangle_painter( + self.simd, b, sampler_x, sampler_y + ) + ); + } + EncodedPaint::Gradient(g) => { + // Note that we are calculating the t values first, store them in a separate + // buffer and then pass that buffer to the iterator instead of calculating + // the t values on the fly in the iterator. The latter would be faster, but + // it would probably increase code size a lot, because the functions for + // position calculation need to be inlined for good performance. + let f32_buf = &mut self.f32_buf[..width * Tile::HEIGHT as usize]; + + match &g.kind { + EncodedKind::Linear(l) => { + calculate_t_vals( + self.simd, + SimdLinearKind::new(self.simd, *l), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + EncodedKind::Sweep(s) => { + calculate_t_vals( + self.simd, + SimdSweepKind::new(self.simd, s), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + EncodedKind::Radial(r) => { + calculate_t_vals( + self.simd, + SimdRadialKind::new(self.simd, r), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + if r.has_undefined() { + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter_with_undefined(self.simd, g, f32_buf) + ); + } else { + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + } + } + } + EncodedPaint::Image(i) => { + let pixmap = match &i.source { + ImageSource::Pixmap(p) => p.clone(), + ImageSource::OpaqueId { id, .. } => image_resolver + .resolve(*id) + .unwrap_or_else(|| panic!("Image {:?} not found in registry", id)), + }; + + let tint = i.tint.as_ref(); + + match (i.has_skew(), i.nearest_neighbor()) { + (false, false) => { + // Axis-aligned with filtering - use optimized plain painters + if i.sampler.quality == ImageQuality::Medium { + fill_complex_paint!( + i.may_have_transparency, + T::plain_medium_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } else { + fill_complex_paint!( + i.may_have_transparency, + T::high_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + (true, false) => { + // Skewed with filtering - use generic filtered painters + if i.sampler.quality == ImageQuality::Medium { + fill_complex_paint!( + i.may_have_transparency, + T::medium_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } else { + fill_complex_paint!( + i.may_have_transparency, + T::high_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + (false, true) => { + fill_complex_paint!( + i.may_have_transparency, + T::plain_nn_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + (true, true) => { + fill_complex_paint!( + i.may_have_transparency, + T::nn_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + } + EncodedPaint::ExternalTexture(_) => { + unimplemented!("External textures are not supported by `vello_cpu`") + } + } + } + } + } + + /// Blend the top blend buffer into the buffer below it. + /// + /// This pops the top buffer from the blend stack and composites it onto the + /// buffer below using the specified blend mode. This is the core operation for + /// layer composition. + pub(crate) fn blend(&mut self, blend_mode: BlendMode) { + let (source_buffer, rest) = self.blend_buf.split_last_mut().unwrap(); + let target_buffer = rest.last_mut().unwrap(); + + if blend_mode == BlendMode::default() { + T::alpha_composite_buffer(self.simd, target_buffer, source_buffer, None); + } else { + T::blend( + self.simd, + target_buffer, + // `start_x` and `start_y` are only needed to sample the correct position + // of a mask, so we can just pass dummy values here. + 0, + 0, + source_buffer + .chunks_exact(T::Composite::LENGTH) + .map(|s| T::Composite::from_slice(self.simd, s)), + blend_mode, + None, + None, + ); + } + } + + /// Apply a clipping mask from the top buffer to the buffer below. + /// + /// Uses the top buffer's alpha channel as a mask, multiplying it with the buffer + /// below. This implements clipping by masking out pixels outside the clip region. + fn clip(&mut self, x: usize, width: usize, alphas: Option<&[u8]>) { + let (source_buffer, rest) = self.blend_buf.split_last_mut().unwrap(); + let target_buffer = rest.last_mut().unwrap(); + + let source_buffer = + &mut source_buffer[x * TILE_HEIGHT_COMPONENTS..][..TILE_HEIGHT_COMPONENTS * width]; + let target_buffer = + &mut target_buffer[x * TILE_HEIGHT_COMPONENTS..][..TILE_HEIGHT_COMPONENTS * width]; + + T::alpha_composite_buffer(self.simd, target_buffer, source_buffer, alphas); + } +} + +/// A trait for objects that can render pixel data into buffers. +/// +/// Painters abstract over different content sources (gradients, images, etc.) and can +/// generate pixel data in either u8 or f32 format. Implementations should provide at least +/// one of these methods; the other can delegate through conversion. +/// +/// Note: Some painters may only efficiently support one numeric type. The implementation +/// may convert between types as needed. +pub trait Painter { + /// Paint pixel data into a u8 buffer (values in 0-255 range). + fn paint_u8(&mut self, buf: &mut [u8]); + + /// Paint pixel data into an f32 buffer (values in 0.0-1.0 range). + fn paint_f32(&mut self, buf: &mut [f32]); +} + +/// Extension trait for creating position vectors for gradient and image sampling. +/// +/// This trait provides a method to generate SIMD vectors of positions that advance +/// correctly across a tile. It's used by painters to compute per-pixel coordinates +/// for sampling operations. +pub trait PosExt { + /// Create a position vector that advances appropriately across a tile. + /// + /// Given a starting position and per-pixel advances in x and y directions, + /// generates a SIMD vector with the correct position for each element. + fn splat_pos(simd: S, pos: f32, x_advance: f32, y_advance: f32) -> Self; +} + +impl PosExt for f32x4 { + #[inline(always)] + fn splat_pos(simd: S, pos: f32, _: f32, y_advance: f32) -> Self { + let columns: [f32; Tile::HEIGHT as usize] = [0.0, 1.0, 2.0, 3.0]; + let column_mask: Self = columns.simd_into(simd); + + column_mask.mul_add(Self::splat(simd, y_advance), Self::splat(simd, pos)) + } +} + +impl PosExt for f32x8 { + #[inline(always)] + fn splat_pos(simd: S, pos: f32, x_advance: f32, y_advance: f32) -> Self { + simd.combine_f32x4( + f32x4::splat_pos(simd, pos, x_advance, y_advance), + f32x4::splat_pos(simd, pos + x_advance, x_advance, y_advance), + ) + } +} + +/// Intermediate shader result with color channels stored separately for efficient processing. +/// +/// This structure holds 8 pixels worth of data in planar format (separate R, G, B, A vectors). +/// The planar layout is more efficient for certain SIMD operations before final interleaving. +pub(crate) struct ShaderResultF32 { + /// Red channel values for 8 pixels. + pub(crate) r: f32x8, + /// Green channel values for 8 pixels. + pub(crate) g: f32x8, + /// Blue channel values for 8 pixels. + pub(crate) b: f32x8, + /// Alpha channel values for 8 pixels. + pub(crate) a: f32x8, +} + +impl ShaderResultF32 { + /// Convert from planar format to interleaved RGBA format. + /// + /// Returns two f32x16 vectors containing 8 pixels (4 RGBA components each) + /// with channels interleaved in the standard RGBA order. + #[inline(always)] + pub(crate) fn get(&self) -> (f32x16, f32x16) { + let (r_1, r_2) = self.r.simd.split_f32x8(self.r); + let (g_1, g_2) = self.g.simd.split_f32x8(self.g); + let (b_1, b_2) = self.b.simd.split_f32x8(self.b); + let (a_1, a_2) = self.a.simd.split_f32x8(self.a); + + let first = self.r.simd.combine_f32x8( + self.r.simd.combine_f32x4(r_1, g_1), + self.r.simd.combine_f32x4(b_1, a_1), + ); + + let second = self.r.simd.combine_f32x8( + self.r.simd.combine_f32x4(r_2, g_2), + self.r.simd.combine_f32x4(b_2, a_2), + ); + + (first, second) + } +} + +mod macros { + /// Implements the `Painter` trait for an iterator that produces f32x16 SIMD vectors. + /// + /// This macro generates both `paint_u8` and `paint_f32` methods, converting between + /// formats as needed. Used for painters that work natively with high-precision f32 data. + macro_rules! f32x16_painter { + ($($type_path:tt)+) => { + impl crate::fine::Painter for $($type_path)+ { + fn paint_u8(&mut self, buf: &mut [u8]) { + use vello_common::fearless_simd::*; + use crate::fine::NumericVec; + + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + let converted = u8x16::::from_f32(next.simd, next); + converted.store_slice(chunk); + } + }) + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + next.store_slice(chunk); + } + }) + } + } + }; + } + + /// Implements the `Painter` trait for an iterator that produces u8x16 SIMD vectors. + /// + /// This macro generates both `paint_u8` and `paint_f32` methods, converting between + /// formats as needed. Used for painters that work natively with low-precision u8 data. + macro_rules! u8x16_painter { + ($($type_path:tt)+) => { + impl crate::fine::Painter for $($type_path)+ { + fn paint_u8(&mut self, buf: &mut [u8]) { + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + next.store_slice(chunk); + } + }) + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + use vello_common::fearless_simd::*; + use crate::fine::NumericVec; + + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + let converted = f32x16::::from_u8(next.simd, next); + converted.store_slice(chunk); + } + }) + } + } + }; + } + + pub(crate) use f32x16_painter; + pub(crate) use u8x16_painter; +} diff --git a/vendor/vello_cpu/src/layer_manager.rs b/vendor/vello_cpu/src/layer_manager.rs new file mode 100644 index 0000000000..62c1a99165 --- /dev/null +++ b/vendor/vello_cpu/src/layer_manager.rs @@ -0,0 +1,154 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Layer management for filter effects rendering. +//! +//! This module provides infrastructure for managing persistent layer buffers +//! that can be rendered to independently and then composited together. This is +//! necessary for spatial filter effects that require access to a fully-rendered +//! layer (e.g., Gaussian blur). +//! +//! Layers are stored as Pixmap instances (row-major, u8 RGBA format), which +//! allows efficient filter operations without layout conversion overhead. +//! Each layer maintains its own bounding box in wide tile coordinates, allowing +//! efficient memory usage for layers that don't span the entire render target. + +use crate::region::Region; +use hashbrown::HashMap; +use vello_common::coarse::WideTilesBbox; +use vello_common::pixmap::Pixmap; + +/// Manages persistent layer storage for filter effects. +/// +/// Each layer is allocated as a `Pixmap` with row-major RGBA8 format. +/// This eliminates conversion overhead for filter operations. +#[derive(Debug)] +pub struct LayerManager { + /// Map of layer ID to (Pixmap, wtile bounding box). + /// The Pixmap contains the layer's pixel data, and the Bbox defines which + /// wide tiles this layer occupies (in wide tile coordinates). + layers: HashMap, + /// Next available layer ID for automatic allocation. + next_id: u32, + /// Reusable scratch buffer for filter operations that need temporary storage. + /// Examples include separable convolution passes (e.g., Gaussian blur) or + /// intermediate compositing results. This buffer is lazily allocated and + /// automatically resized as needed to avoid repeated allocations. + scratch_buffer: Option, +} + +impl Default for LayerManager { + /// Creates a new, empty layer manager with no allocated layers. + /// + /// Layer IDs start from 1 (ID 0 is reserved for internal use). + fn default() -> Self { + Self { + layers: HashMap::new(), + next_id: 1, + scratch_buffer: None, + } + } +} + +impl LayerManager { + /// Create a new, empty layer manager. + /// + /// Layers can be registered on-demand using [`register_layer`](Self::register_layer). + pub fn new() -> Self { + Self::default() + } + + /// Register a layer with a specific ID, wide tile bounding box, and pixel data. + /// + /// The provided `Pixmap` should already contain the layer's pixel data (e.g., the + /// result of rendering a layer or applying a filter effect). + /// + /// If a layer with this ID already exists, this method does nothing (no replacement). + /// + /// # Parameters + /// - `layer_id`: A unique identifier for this layer. Layer ID 0 is reserved for internal use. + /// - `wtile_bbox`: The bounding box of the layer in wide tile coordinates. This defines + /// the region of the layer that contains valid pixel data, enabling efficient memory + /// usage for layers that don't span the entire render target. + /// - `pixmap`: The pixel data for this layer in row-major RGBA8 format. + pub fn register_layer(&mut self, layer_id: u32, wtile_bbox: WideTilesBbox, pixmap: Pixmap) { + self.layers.insert(layer_id, (pixmap, wtile_bbox)); + + if layer_id >= self.next_id { + self.next_id = layer_id + 1; + } + } + + /// Get a mutable `Region` view into a specific wide tile within a layer. + /// + /// This extracts a tile-sized region from the layer's Pixmap, allowing + /// rendering or compositing operations on individual tiles without copying + /// pixel data. The returned Region provides a mutable view into the underlying + /// layer buffer. + /// + /// Returns [`None`] if: + /// - The layer doesn't exist + /// - The tile position is outside the layer's bounding box + /// - The tile extraction fails (e.g., invalid coordinates) + /// + /// # Parameters + /// - `layer_id`: The ID of the layer to access + /// - `tile_x`: The x-coordinate of the wide tile in global wide tile coordinates + /// - `tile_y`: The y-coordinate of the wide tile in global wide tile coordinates + /// + /// # Coordinate Systems + /// The input coordinates (`tile_x`, `tile_y`) are in *global* wide tile space, + /// but they are automatically converted to *local* coordinates relative to the + /// layer's bounding box before extracting the region. + pub fn layer_tile_region_mut( + &mut self, + layer_id: u32, + tile_x: u16, + tile_y: u16, + ) -> Option> { + let (pixmap, bbox) = self.layers.get_mut(&layer_id)?; + + // Ensure the requested tile is within the layer's allocated bounds + if !bbox.contains(tile_x, tile_y) { + return None; + } + + // Convert global tile coordinates to layer-local (bbox-relative) coordinates + let local_x = tile_x - bbox.x0(); + let local_y = tile_y - bbox.y0(); + + // Extract a mutable Region view of the tile from the underlying pixmap + Region::from_pixmap_tile(pixmap, local_x, local_y) + } + + /// Get or create a scratch buffer of at least the requested dimensions. + /// + /// This buffer is reused across filter operations to minimize allocations. + /// If the existing buffer is large enough, it's reused; otherwise, a new + /// (larger) buffer is allocated. + /// + /// # Parameters + /// - `width`: Minimum width in pixels + /// - `height`: Minimum height in pixels + /// + /// # Returns + /// A mutable reference to a `Pixmap` of at least `width × height` pixels. + /// The actual buffer may be larger than requested if it was previously allocated + /// with larger dimensions. + pub fn get_scratch_buffer(&mut self, width: u16, height: u16) -> &mut Pixmap { + match &mut self.scratch_buffer { + None => { + // No buffer exists yet, allocate a new one + self.scratch_buffer = Some(Pixmap::new(width, height)); + } + Some(buf) if buf.width() < width || buf.height() < height => { + // Existing buffer is too small, resize it + buf.resize(width, height); + } + // Buffer is already large enough, reuse it without reallocation + Some(_) => {} + } + + self.scratch_buffer.as_mut().unwrap() + } +} diff --git a/vendor/vello_cpu/src/lib.rs b/vendor/vello_cpu/src/lib.rs new file mode 100644 index 0000000000..11299b2912 --- /dev/null +++ b/vendor/vello_cpu/src/lib.rs @@ -0,0 +1,186 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// After you edit the crate's doc comment, run this command, then check README.md for any missing links +// cargo rdme --workspace-project=vello_cpu + +//! Vello CPU is a 2D graphics rendering engine written in Rust, for devices with no or underpowered GPUs. +//! +//! We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +//! Vello CPU is being developed as part of work to address shortcomings in Vello. +//! +//! # Usage +//! +//! To use Vello CPU, you need to: +//! +//! - Create a [`RenderContext`][], a 2D drawing context for a fixed-size target area. +//! - For each object in your scene: +//! - Set how the object will be painted, using [`set_paint`][RenderContext::set_paint]. +//! - Set the shape to be drawn for that object, using methods like [`fill_path`][RenderContext::fill_path], +//! [`stroke_path`][RenderContext::stroke_path], or [`glyph_run`][RenderContext::glyph_run]. +//! - Render it to an image using [`RenderContext::render_to_pixmap`][]. +//! +//! ```rust +//! use vello_cpu::{RenderContext, Resources, Pixmap, RenderMode}; +//! use vello_cpu::{color::{palette::css, PremulRgba8}, kurbo::Rect}; +//! let width = 10; +//! let height = 5; +//! let mut context = RenderContext::new(width, height); +//! let mut resources = Resources::new(); +//! context.set_paint(css::MAGENTA); +//! context.fill_rect(&Rect::from_points((3., 1.), (7., 4.))); +//! +//! let mut target = Pixmap::new(width, height); +//! // While calling `flush` is only strictly necessary if you are rendering using +//! // multiple threads, it is recommended to always do this. +//! context.flush(); +//! context.render_to_pixmap(&mut resources, &mut target); +//! +//! let expected_render = b"\ +//! 0000000000\ +//! 0001111000\ +//! 0001111000\ +//! 0001111000\ +//! 0000000000"; +//! let magenta = css::MAGENTA.premultiply().to_rgba8(); +//! let transparent = PremulRgba8 {r: 0, g: 0, b: 0, a: 0}; +//! let mut result = Vec::new(); +//! for pixel in target.data() { +//! if *pixel == magenta { +//! result.push(b'1'); +//! } else if *pixel == transparent { +//! result.push(b'0'); +//! } else { +//! panic!("Got unexpected pixel value {pixel:?}"); +//! } +//! } +//! assert_eq!(&result, expected_render); +//! ``` +//! +//! Feel free to take a look at some further +//! [examples](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) +//! to better understand how to interact with Vello CPU's API, +//! +//! # Features +//! +//! - `std` (enabled by default): Get floating point functions from the standard library +//! (likely using your target's libc). +//! - `libm`: Use floating point implementations from [libm][]. +//! - `png`(enabled by default): Allow loading [`Pixmap`]s from PNG images. +//! Also required for rendering glyphs with an embedded PNG. Implies `std`. +//! - `multithreading`: Enable multi-threaded rendering. Implies `std`. +//! - `text` (enabled by default): Enables glyph rendering ([`glyph_run`][RenderContext::glyph_run]). +//! - `u8_pipeline` (enabled by default): Enable the u8 pipeline, for speed focused rendering using u8 math. +//! The `u8` pipeline will be used for [`OptimizeSpeed`][RenderMode::OptimizeSpeed], if both pipelines are enabled. +//! If you're using Vello CPU for application rendering, you should prefer this pipeline. +//! - `f32_pipeline`: Enable the `f32` pipeline, which is slower but has more accurate +//! results. This is espectially useful for rendering test snapshots. +//! The `f32` pipeline will be used for [`OptimizeQuality`][RenderMode::OptimizeQuality], if both pipelines are enabled. +//! +//! At least one of `std` and `libm` is required; `std` overrides `libm`. +//! At least one of `u8_pipeline` and `f32_pipeline` must be enabled. +//! You might choose to disable one of these pipelines if your application +//! won't use it, so as to reduce binary size. +//! +//! # Caveats +//! +//! Overall, Vello CPU is already very feature-rich and should be ready for +//! production use cases. The main caveat at the moment is that the API is +//! still likely to change and not stable yet. For example, we have +//! known plans to change the API around how image resources are used. +//! +//! Additionally, there are certain APIs that are still very much experimental, +//! including for example support for filters. This will be reflected in the +//! documentation of those APIs. +//! +//! Another caveat is that multi-threading with large thread counts +//! (more than 4) might give diminishing returns, especially when +//! making heavy use of layers and clip paths. +//! +//! # Performance +//! +//! Performance benchmarks can be found [here](https://laurenzv.github.io/vello_chart/), +//! As can be seen, Vello CPU achieves compelling performance on both, +//! aarch64 and x86 platforms. We also have SIMD optimizations for WASM SIMD, +//! meaning that you can expect good performance there as well. +//! +//! # Implementation +//! +//! If you want to gain a better understanding of Vello CPU and the +//! sparse strips paradigm, you can take a look at the [accompanying +//! master's thesis](https://ethz.ch/content/dam/ethz/special-interest/infk/inst-pls/plf-dam/documents/StudentProjects/MasterTheses/2025-Laurenz-Thesis.pdf) +//! that was written on the topic. Note that parts of the descriptions might +//! become outdated as the implementation changes, but it should give a good +//! overview nevertheless. +//! +//! +//! [libm]: https://crates.io/crates/libm +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![forbid(unsafe_code)] +#![expect( + clippy::cast_possible_truncation, + reason = "We cast u16s to u8 in various places where we know for sure that it's < 256" +)] +#![no_std] + +extern crate alloc; +extern crate core; +// Unused in release mode because it's only used directly in the `text_debug` module +// (or transitively in vello_common). +#[cfg(feature = "png")] +use png as _; +#[cfg(feature = "std")] +extern crate std; + +#[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] +compile_error!("vello_cpu must have at least one of the u8 or f32 pipelines enabled"); + +mod render; + +mod dispatch; +mod filter; +#[cfg(feature = "text")] +mod text; +#[cfg(all(feature = "text", feature = "std", debug_assertions))] +mod text_debug; +mod util; + +#[doc(hidden)] +pub mod fine; +#[doc(hidden)] +pub mod layer_manager; +#[doc(hidden)] +pub mod region; + +pub use render::{RenderContext, RenderSettings, Resources}; +// Note: The first one is not something that should be +// exposed, but is currently needed by vello_sparse_tests. +#[cfg(feature = "text")] +pub use glifo::Glyph; +#[cfg(feature = "text")] +pub use text::{CpuGlyphRunBackend, GlyphRunBuilder}; +pub use vello_common::fearless_simd::Level; +pub use vello_common::mask::Mask; +pub use vello_common::paint::{Image, ImageSource, Paint, PaintType}; +pub use vello_common::pixmap::Pixmap; +pub use vello_common::{color, kurbo, peniko}; + +/// The selected rendering mode. +/// For using [`RenderMode::OptimizeQuality`] you also need to enable `f32_pipeline` feature. +#[derive(Copy, Clone, Debug, Default)] +pub enum RenderMode { + /// Optimize speed (by performing calculations with u8/16). + #[default] + OptimizeSpeed, + /// Optimize quality (by performing calculations with f32). + OptimizeQuality, +} diff --git a/vendor/vello_cpu/src/region.rs b/vendor/vello_cpu/src/region.rs new file mode 100644 index 0000000000..00e78062ad --- /dev/null +++ b/vendor/vello_cpu/src/region.rs @@ -0,0 +1,243 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Splitting a single mutable buffer into regions that can be accessed concurrently. + +use crate::fine::COLOR_COMPONENTS; +use alloc::vec::Vec; +use vello_common::coarse::WideTile; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; + +#[derive(Debug)] +pub struct Regions<'a> { + regions: Vec>, +} + +impl<'a> Regions<'a> { + /// Creates regions from a buffer where the buffer dimensions match the render dimensions. + pub fn new(width: u16, height: u16, buffer: &'a mut [u8]) -> Self { + Self::new_at_offset(width, height, 0, 0, width, height, buffer) + } + + /// Creates regions from a buffer at a specific offset. + /// + /// This is used for rendering to a sub-region of a larger buffer. The regions + /// cover the area of size (`width` × `height`) placed at pixel offset + /// (`dst_x`, `dst_y`) in the destination buffer. + /// + /// # Arguments + /// * `width` - Width of the content being rendered + /// * `height` - Height of the content being rendered + /// * `dst_x` - X offset in the destination buffer + /// * `dst_y` - Y offset in the destination buffer + /// * `dst_buffer_width` - Total width of the destination buffer + /// * `dst_buffer_height` - Total height of the destination buffer + /// * `buffer` - The destination buffer (RGBA, 4 bytes per pixel) + pub fn new_at_offset( + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + mut buffer: &'a mut [u8], + ) -> Self { + // Calculate effective render area (clamped to destination bounds) + let effective_width = width.min(dst_buffer_width.saturating_sub(dst_x)) as usize; + let effective_height = height.min(dst_buffer_height.saturating_sub(dst_y)) as usize; + + if effective_width == 0 || effective_height == 0 { + return Self { + regions: Vec::new(), + }; + } + + let width_regions = effective_width.div_ceil(WideTile::WIDTH as usize); + let height_regions = effective_height.div_ceil(Tile::HEIGHT as usize); + + let mut regions = Vec::with_capacity(width_regions * height_regions); + + let row_stride = dst_buffer_width as usize * COLOR_COMPONENTS; + let render_row_bytes = effective_width * COLOR_COMPONENTS; + + // Calculate starting offset in the buffer + let start_offset = (dst_y as usize * row_stride) + (dst_x as usize * COLOR_COMPONENTS); + buffer = &mut buffer[start_offset..]; + + let mut next_lines: [&'a mut [u8]; Tile::HEIGHT as usize] = + [&mut [], &mut [], &mut [], &mut []]; + + for y in 0..height_regions { + let base_y = y * Tile::HEIGHT as usize; + let region_height = (Tile::HEIGHT as usize).min(effective_height - base_y); + + // Extract Tile::HEIGHT rows from the buffer + // Each row is at row_stride intervals + for line in next_lines.iter_mut().take(region_height) { + // Take only the render area portion of this row + let (render_portion, rest) = buffer.split_at_mut(render_row_bytes); + *line = render_portion; + // Skip the remainder of this buffer row to get to the next row. + // On the last row (when clipped to pixmap edge), there may be less + // data remaining, so cap the skip to what's available. + let skip = (row_stride - render_row_bytes).min(rest.len()); + buffer = &mut rest[skip..]; + } + + // Split each row horizontally into tile-width chunks + for x in 0..width_regions { + let mut areas: [&mut [u8]; Tile::HEIGHT as usize] = + [&mut [], &mut [], &mut [], &mut []]; + + let base_x = x * WideTile::WIDTH as usize; + let region_width_bytes = + ((WideTile::WIDTH as usize).min(effective_width - base_x)) * COLOR_COMPONENTS; + + for h in 0..region_height { + let next = core::mem::take(&mut next_lines[h]); + let (head, tail) = next.split_at_mut(region_width_bytes); + areas[h] = head; + next_lines[h] = tail; + } + + regions.push(Region::new( + areas, + u16::try_from(x).unwrap(), + u16::try_from(y).unwrap(), + (region_width_bytes / COLOR_COMPONENTS) as u16, + region_height as u16, + )); + } + } + + Self { regions } + } + + /// Apply the given function to each region. The functions will be applied + /// in parallel in the current threadpool. + #[cfg(feature = "multithreading")] + pub fn update_regions_par(&mut self, func: impl Fn(&mut Region<'_>) + Send + Sync) { + use rayon::iter::ParallelIterator; + use rayon::prelude::IntoParallelRefMutIterator; + + self.regions.par_iter_mut().for_each(func); + } + + /// Apply the given function to each region. + pub fn update_regions(&mut self, func: impl FnMut(&mut Region<'_>)) { + self.regions.iter_mut().for_each(func); + } +} + +/// A rectangular region containing the pixels from one wide tile. +/// +/// For wide tiles at the right/bottom edge, it might contain less pixels +/// than the actual wide tile, if the pixmap width/height isn't a multiple of the +/// tile width/height. +#[derive(Default, Debug)] +pub struct Region<'a> { + /// The x coordinate of the wide tile this region covers. + pub(crate) x: u16, + /// The y coordinate of the wide tile this region covers. + pub(crate) y: u16, + pub width: u16, + pub height: u16, + areas: [&'a mut [u8]; Tile::HEIGHT as usize], +} + +impl<'a> Region<'a> { + pub(crate) fn new( + areas: [&'a mut [u8]; Tile::HEIGHT as usize], + x: u16, + y: u16, + width: u16, + height: u16, + ) -> Self { + Self { + areas, + x, + y, + width, + height, + } + } + + /// Extracts a `Region` from a pixmap at the specified tile coordinates. + /// + /// The region corresponds to a wide tile area (`WideTile::WIDTH` × `Tile::HEIGHT` pixels), + /// starting at pixel coordinates `(tile_x * WideTile::WIDTH, tile_y * Tile::HEIGHT)`. + /// Regions at the right or bottom edges may be smaller if they extend beyond the pixmap bounds. + /// + /// Returns `None` if the tile coordinates are completely outside the pixmap bounds. + /// + /// # Arguments + /// * `pixmap` - The pixmap to extract from + /// * `tile_x` - Tile column index (in tile units, not pixels) + /// * `tile_y` - Tile row index (in tile units, not pixels) + pub(crate) fn from_pixmap_tile( + pixmap: &'a mut Pixmap, + tile_x: u16, + tile_y: u16, + ) -> Option { + let pixmap_width = pixmap.width(); + let pixmap_height = pixmap.height(); + + // Calculate pixel coordinates for this tile + let base_x = tile_x * WideTile::WIDTH; + let base_y = tile_y * Tile::HEIGHT; + + // Check bounds + if base_x >= pixmap_width || base_y >= pixmap_height { + return None; + } + + // Calculate actual region dimensions (might be smaller at edges) + let region_width = WideTile::WIDTH.min(pixmap_width - base_x); + let region_height = Tile::HEIGHT.min(pixmap_height - base_y); + + // Get mutable access to the pixmap's buffer + let buffer = pixmap.data_as_u8_slice_mut(); + + // Split buffer into row slices for this tile + let row_stride = pixmap_width as usize * COLOR_COMPONENTS; + let start_offset = (base_y as usize * row_stride) + (base_x as usize * COLOR_COMPONENTS); + let region_width_bytes = region_width as usize * COLOR_COMPONENTS; + + // Skip to the start of our tile region + let tile_buffer = &mut buffer[start_offset..]; + + // Extract individual row slices using safe split operations + let mut areas: [&mut [u8]; Tile::HEIGHT as usize] = [&mut [], &mut [], &mut [], &mut []]; + + // Use split_at_mut to safely extract each row + let mut remaining = tile_buffer; + for (i, area) in areas.iter_mut().take(region_height as usize).enumerate() { + if i > 0 { + // Skip rows we've already processed (advance by stride - region_width_bytes) + let skip = row_stride - region_width_bytes; + remaining = &mut remaining[skip..]; + } + + let (row, rest) = remaining.split_at_mut(region_width_bytes.min(remaining.len())); + *area = row; + remaining = rest; + } + + Some(Self::new( + areas, + tile_x, + tile_y, + region_width, + region_height, + )) + } + + pub(crate) fn row_mut(&mut self, y: u16) -> &mut [u8] { + self.areas[usize::from(y)] + } + + pub fn areas(&mut self) -> &mut [&'a mut [u8]; Tile::HEIGHT as usize] { + &mut self.areas + } +} diff --git a/vendor/vello_cpu/src/render.rs b/vendor/vello_cpu/src/render.rs new file mode 100644 index 0000000000..fe4d803108 --- /dev/null +++ b/vendor/vello_cpu/src/render.rs @@ -0,0 +1,917 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Basic render operations. + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +#[cfg(feature = "text")] +use crate::text::{GlyphAtlasResources, GlyphRunBuilder}; +#[cfg(feature = "text")] +use glifo::GlyphPrepCache; + +#[cfg(feature = "multithreading")] +use crate::dispatch::multi_threaded::MultiThreadedDispatcher; +use crate::dispatch::single_threaded::SingleThreadedDispatcher; +use crate::kurbo::{PathEl, Point}; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use hashbrown::HashMap; +use vello_common::blurred_rounded_rect::BlurredRoundedRectangle; +use vello_common::encode::{EncodeExt, EncodedPaint}; +use vello_common::fearless_simd::Level; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::{Affine, BezPath, Rect, Stroke}; +use vello_common::mask::Mask; +use vello_common::paint::{ImageId, ImageResolver, Paint, PaintType, Tint}; +use vello_common::peniko::color::palette::css::BLACK; +use vello_common::peniko::{BlendMode, Fill}; +use vello_common::pixmap::Pixmap; +use vello_common::render_state::RenderState; +use vello_common::util::is_axis_aligned; + +#[cfg(feature = "text")] +pub(crate) const DEFAULT_GLYPH_ATLAS_SIZE: u16 = 4096; +// Why do we need this? The reason is that the way uploaded images work in Vello Hybrid +// is different from how they work in Vello CPU. +// +// In Vello Hybrid, all images, regardless of whether they are user-uploaded +// images or cached glyphs, are stored in an image atlas at a certain location. An image ID then +// uniquely resolves to an atlas page index + a location on that page. Whenever we want to +// cache a new glyph, we simply allocate a location in the image atlas and then return the image +// ID associated with that location. +// +// On Vello CPU, it works differently: An image ID is associated with a complete pixmap. +// If a user uploads an image, instead of blitting it into a bigger image atlas, we just +// store the user-provided pixmap and associate an image ID with the whole pixmap. However, +// for glyph caching to work we need the same semantics as in Vello Hybrid. Therefore, we +// use a marker to determine whether an image ID refers to a normal uploaded image or a cached +// glyph and apply special handling based on that. +// +// All IDs < than this value are reserved for normal images, all IDs >= this value are +// reserved for atlas pages. +pub(crate) const ATLAS_IMAGE_ID_BASE: u32 = u32::MAX / 2; + +/// Persistent resources required by Vello CPU for rendering. +#[derive(Debug, Default)] +pub struct Resources { + pub(crate) image_registry: ImageRegistry, + #[cfg(feature = "text")] + pub(crate) glyph_prep_cache: GlyphPrepCache, + // Will be initialized lazily on first use. + #[cfg(feature = "text")] + pub(crate) glyph_resources: Option, +} + +impl Resources { + /// Create a new set of renderer resources. + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn before_render(&mut self) { + #[cfg(feature = "text")] + self.prepare_glyph_cache(); + } + + pub(crate) fn after_render(&mut self) { + #[cfg(feature = "text")] + self.maintain_glyph_cache(); + } +} + +/// A render context for CPU-based 2D graphics rendering. +/// +/// This is the main entry point for drawing operations. It maintains the current +/// rendering state (transforms, paint, stroke, etc.) and dispatches drawing commands +/// to the underlying rasterization engine. +#[derive(Debug)] +pub struct RenderContext { + /// Width of the render target in pixels. + pub(crate) width: u16, + /// Height of the render target in pixels. + pub(crate) height: u16, + /// The current rendering state. + pub(crate) state: RenderState, + /// The current mask in place. + pub(crate) mask: Option, + /// Temporary path buffer to avoid repeated allocations. + pub(crate) temp_path: BezPath, + /// Optional threshold for aliasing. + pub(crate) aliasing_threshold: Option, + pub(crate) encoded_paints: Vec, + pub(crate) filter: Option, + #[cfg_attr( + not(feature = "text"), + allow(dead_code, reason = "used when the `text` feature is enabled") + )] + pub(crate) render_settings: RenderSettings, + dispatcher: Box, +} + +/// Settings to apply to the render context. +#[derive(Copy, Clone, Debug)] +pub struct RenderSettings { + /// The SIMD level that should be used for rendering operations. + pub level: Level, + /// The number of worker threads that should be used for rendering. Only has an effect + /// if the `multithreading` feature is active. + pub num_threads: u16, + /// Whether to prioritize speed or quality when rendering. + /// + /// For most cases (especially for real-time rendering), it is highly recommended to set + /// this to `OptimizeSpeed`. If accuracy is a more significant concern (for example for visual + /// regression testing), then you can set this to `OptimizeQuality`. + /// + /// Currently, the only difference this makes is that when choosing `OptimizeSpeed`, rasterization + /// will happen using u8/u16, while `OptimizeQuality` will use a f32-based pipeline. + pub render_mode: RenderMode, +} + +impl Default for RenderSettings { + fn default() -> Self { + Self { + level: Level::try_detect().unwrap_or(Level::baseline()), + #[cfg(feature = "multithreading")] + num_threads: (std::thread::available_parallelism() + .unwrap() + .get() + .saturating_sub(1) as u16) + .min(8), + #[cfg(not(feature = "multithreading"))] + num_threads: 0, + render_mode: RenderMode::OptimizeSpeed, + } + } +} + +impl RenderContext { + /// Create a new render context with the given width and height in pixels. + pub fn new(width: u16, height: u16) -> Self { + Self::new_with(width, height, RenderSettings::default()) + } + + /// Create a new render context with specific settings. + pub fn new_with(width: u16, height: u16, settings: RenderSettings) -> Self { + #[cfg(feature = "multithreading")] + let dispatcher: Box = if settings.num_threads == 0 { + Box::new(SingleThreadedDispatcher::new(width, height, settings.level)) + } else { + Box::new(MultiThreadedDispatcher::new( + width, + height, + settings.num_threads, + settings.level, + )) + }; + + #[cfg(not(feature = "multithreading"))] + let dispatcher: Box = + { Box::new(SingleThreadedDispatcher::new(width, height, settings.level)) }; + + let encoded_paints = vec![]; + let temp_path = BezPath::new(); + let aliasing_threshold = None; + + Self { + width, + height, + dispatcher, + state: RenderState::default(), + aliasing_threshold, + render_settings: settings, + mask: None, + temp_path, + encoded_paints, + filter: None, + } + } + + fn encode_current_paint(&mut self) -> Paint { + match self.state.paint.clone() { + PaintType::Solid(s) => s.into(), + PaintType::Gradient(g) => { + // TODO: Add caching? + g.encode_into( + &mut self.encoded_paints, + self.state.transform * self.state.paint_transform, + None, + ) + } + PaintType::Image(i) => i.encode_into( + &mut self.encoded_paints, + self.state.transform * self.state.paint_transform, + self.state.tint, + ), + } + } + + /// Fill a path. + pub fn fill_path(&mut self, path: &BezPath) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + ctx.dispatcher.fill_path( + path, + ctx.state.fill_rule, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + /// Stroke a path. + pub fn stroke_path(&mut self, path: &BezPath) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + ctx.dispatcher.stroke_path( + path, + &ctx.state.stroke, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + /// Fill a rectangle. + pub fn fill_rect(&mut self, rect: &Rect) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + + // Fast path: Use optimized rect filling if we have no skew in the path transform + // and anti-aliasing is enabled. + // TODO: Maybe also support no anti-aliasing in the fast path + if is_axis_aligned(&ctx.state.transform) && ctx.aliasing_threshold.is_none() { + // Transform the rect to screen coordinates. + let transformed_rect = ctx.state.transform.transform_rect_bbox(*rect); + ctx.dispatcher.fill_rect_fast( + &transformed_rect, + paint, + ctx.state.blend_mode, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + } else { + // Fall back to path-based rendering for rotated/skewed transforms. + ctx.rect_to_temp_path(rect); + ctx.dispatcher.fill_path( + &ctx.temp_path, + ctx.state.fill_rule, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + } + }); + } + + /// Stroke a rectangle. + pub fn stroke_rect(&mut self, rect: &Rect) { + self.with_optional_filter(|ctx| { + ctx.rect_to_temp_path(rect); + let paint = ctx.encode_current_paint(); + ctx.dispatcher.stroke_path( + &ctx.temp_path, + &ctx.state.stroke, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + fn rect_to_temp_path(&mut self, rect: &Rect) { + self.temp_path.truncate(0); + self.temp_path + .push(PathEl::MoveTo(Point::new(rect.x0, rect.y0))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x1, rect.y0))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x1, rect.y1))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x0, rect.y1))); + self.temp_path.push(PathEl::ClosePath); + } + + /// Fill a blurred rectangle with the given corner radius and standard deviation. + /// + /// Note that this only works properly if the current paint is set to a solid color. + /// If not, it will fall back to using black as the fill color. + pub fn fill_blurred_rounded_rect(&mut self, rect: &Rect, radius: f32, std_dev: f32) { + let rect = rect.abs(); + let color = match self.state.paint { + PaintType::Solid(s) => s, + // Fallback to black when attempting to blur a rectangle with an image/gradient paint + _ => BLACK, + }; + + let blurred_rect = BlurredRoundedRectangle { + rect, + color, + radius, + std_dev, + }; + + // The actual rectangle we paint needs to be larger so that the blurring effect + // is not cut off. + // The impulse response of a gaussian filter is infinite. + // For performance reason we cut off the filter at some extent where the response is close to zero. + let kernel_size = 2.5 * std_dev; + let inflated_rect = rect.inflate(f64::from(kernel_size), f64::from(kernel_size)); + let transform = self.state.transform * self.state.paint_transform; + + self.rect_to_temp_path(&inflated_rect); + + let paint = blurred_rect.encode_into(&mut self.encoded_paints, transform, None); + self.dispatcher.fill_path( + &self.temp_path, + Fill::NonZero, + self.state.transform, + paint, + self.state.blend_mode, + self.aliasing_threshold, + self.mask.clone(), + &self.encoded_paints, + ); + } + + /// Creates a builder for drawing a run of glyphs that have the same attributes. + #[cfg(feature = "text")] + pub fn glyph_run<'a>( + &'a mut self, + resources: &'a mut Resources, + font: &crate::peniko::FontData, + ) -> GlyphRunBuilder<'a> { + glifo::GlyphRunBuilder::new( + font.clone(), + self.state.transform, + crate::text::CpuGlyphRunBackend { + ctx: self, + resources, + atlas_cache_enabled: false, + }, + ) + } + + /// Push a new layer with the given properties. + /// + /// Note that the mask, if provided, needs to have the same size as the render context. Otherwise, + /// it will be ignored. In addition to that, the mask will not be affected by the current + /// transformation matrix in place. + pub fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + blend_mode: Option, + opacity: Option, + mask: Option, + filter: Option, + ) { + let mask = mask.and_then(|m| { + if m.width() != self.width || m.height() != self.height { + None + } else { + Some(m) + } + }); + + let blend_mode = blend_mode.unwrap_or_default(); + let opacity = opacity.unwrap_or(1.0); + + self.dispatcher.push_layer( + clip_path, + self.state.fill_rule, + self.state.transform, + blend_mode, + opacity, + self.aliasing_threshold, + mask, + filter, + ); + } + + /// Push a new clip layer. + /// + /// See the explanation in the [clipping](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) + /// example for how this method differs from `push_clip_path`. + pub fn push_clip_layer(&mut self, path: &BezPath) { + self.push_layer(Some(path), None, None, None, None); + } + + /// Push a new blend layer. + pub fn push_blend_layer(&mut self, blend_mode: BlendMode) { + self.push_layer(None, Some(blend_mode), None, None, None); + } + + /// Push a new opacity layer. + pub fn push_opacity_layer(&mut self, opacity: f32) { + self.push_layer(None, None, Some(opacity), None, None); + } + + /// Push a new mask layer. The mask needs to have the same dimensions as the + /// render context. The mask will not be affected by the current transform + /// in place. + /// + /// See the explanation in the [masking](https://github.com/linebender/vello/tree/main/sparse_strips/masking/examples) + /// example for how this method differs from `set_mask`. + pub fn push_mask_layer(&mut self, mask: Mask) { + self.push_layer(None, None, None, Some(mask), None); + } + + /// Push a filter layer that affects all subsequent drawing operations. + /// + /// WARNING: Note that filters are currently incomplete and experimental. In + /// particular, they will lead to a panic when used in combination with + /// multi-threaded rendering. + pub fn push_filter_layer(&mut self, filter: Filter) { + self.push_layer(None, None, None, None, Some(filter)); + } + + /// Set the aliasing threshold. + /// + /// If set to `None` (which is the recommended option in nearly all cases), + /// anti-aliasing will be applied. + /// + /// If instead set to some value, then a pixel will be fully painted if + /// the coverage is bigger than the threshold (between 0 and 255), otherwise + /// it will not be painted at all. + /// + /// Note that there is no performance benefit to disabling anti-aliasing and + /// this functionality is simply provided for compatibility. + pub fn set_aliasing_threshold(&mut self, aliasing_threshold: Option) { + self.aliasing_threshold = aliasing_threshold; + } + + /// Pop the last-pushed layer. + pub fn pop_layer(&mut self) { + self.dispatcher.pop_layer(); + } + + /// Set the current stroke. + pub fn set_stroke(&mut self, stroke: Stroke) { + self.state.stroke = stroke; + } + + /// Get the current stroke. + pub fn stroke(&self) -> &Stroke { + &self.state.stroke + } + + /// Get a mutable reference to the current stroke. + #[cfg(feature = "text")] + pub(crate) fn stroke_mut(&mut self) -> &mut Stroke { + &mut self.state.stroke + } + + /// Set the current paint. + /// + /// If the paint is an image with `ImageSource::OpaqueId`, it will be + /// resolved to the corresponding pixmap at rasterization time. + /// Make sure to register images with [`Resources::register_image`] first. + pub fn set_paint(&mut self, paint: impl Into) { + self.state.paint = paint.into(); + } + + /// Get the current paint. + pub fn paint(&self) -> &PaintType { + &self.state.paint + } + + /// Set the tint for subsequent image paint operations. + pub fn set_tint(&mut self, tint: Option) { + self.state.tint = tint; + } + + /// Clear the tint, so subsequent image paints are drawn without tinting. + pub fn reset_tint(&mut self) { + self.state.tint = None; + } + + /// Set the blend mode that should be used when drawing objects. + pub fn set_blend_mode(&mut self, blend_mode: BlendMode) { + self.state.blend_mode = blend_mode; + } + + /// Get the currently active blend mode. + pub fn blend_mode(&self) -> BlendMode { + self.state.blend_mode + } + + /// Set the current paint transform. + /// + /// The paint transform is applied to the paint after the transform of the geometry the paint + /// is drawn in, i.e., the paint transform is applied after the global transform. This allows + /// transforming the paint independently from the drawn geometry. + pub fn set_paint_transform(&mut self, paint_transform: Affine) { + self.state.paint_transform = paint_transform; + } + + /// Get the current paint transform. + pub fn paint_transform(&self) -> &Affine { + &self.state.paint_transform + } + + /// Reset the current paint transform. + pub fn reset_paint_transform(&mut self) { + self.state.paint_transform = Affine::IDENTITY; + } + + /// Set the current fill rule. + pub fn set_fill_rule(&mut self, fill_rule: Fill) { + self.state.fill_rule = fill_rule; + } + + /// Set the mask to use for path-painting operations. The mask needs to + /// have the same dimensions as the render context. The mask will not be + /// affected by the current transform in place. + /// + /// See the explanation in the [masking](https://github.com/linebender/vello/tree/main/sparse_strips/masking/examples) + /// example for how this method differs from `push_mask_layer`. + pub fn set_mask(&mut self, mask: Mask) { + self.mask = Some(mask); + } + + /// Reset the mask that is used for path-painting operations. + pub fn reset_mask(&mut self) { + self.mask = None; + } + + /// Get the current fill rule. + pub fn fill_rule(&self) -> &Fill { + &self.state.fill_rule + } + + /// Set the current transform. + pub fn set_transform(&mut self, transform: Affine) { + self.state.transform = transform; + } + + /// Get the current transform. + pub fn transform(&self) -> &Affine { + &self.state.transform + } + + /// Reset the current transform. + pub fn reset_transform(&mut self) { + self.state.transform = Affine::IDENTITY; + } + + /// Apply filter to the current paint (affects next drawn elements). + /// + /// This sets a filter that will be applied to the next drawn element. + /// To apply a filter to multiple elements, use `push_filter_layer` instead. + pub fn set_filter_effect(&mut self, filter: Filter) { + self.filter = Some(filter); + } + + /// Reset the current filter effect. + pub fn reset_filter_effect(&mut self) { + self.filter = None; + } + + /// Reset the render context. + pub fn reset(&mut self) { + self.dispatcher.reset(); + self.encoded_paints.clear(); + self.mask = None; + self.state.reset(); + } + + /// Push a new clip path to the clip stack. + /// + /// See the explanation in the [clipping](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) + /// example for how this method differs from `push_clip_layer`. + pub fn push_clip_path(&mut self, path: &BezPath) { + self.dispatcher.push_clip_path( + path, + self.state.fill_rule, + self.state.transform, + self.aliasing_threshold, + ); + } + + /// Pop a clip path from the clip stack. + /// + /// Note that unlike `push_clip_layer`, it is permissible to have pending + /// pushed clip paths before finishing the rendering operation. + pub fn pop_clip_path(&mut self) { + self.dispatcher.pop_clip_path(); + } + + /// Flush any pending operations. + /// + /// This is a no-op when using the single-threaded render mode, and can be ignored. + /// For multi-threaded rendering, you _have_ to call this before rasterizing, otherwise + /// the program will panic. + pub fn flush(&mut self) { + self.dispatcher.flush(&self.encoded_paints); + } + + /// Render the current context into a buffer. + /// The buffer is expected to be in premultiplied RGBA8 format with length `width * height * 4` + pub fn render_to_buffer( + &self, + resources: &mut Resources, + buffer: &mut [u8], + width: u16, + height: u16, + render_mode: RenderMode, + ) { + // TODO: Maybe we should move those checks into the dispatcher. + let wide = self.dispatcher.wide(); + assert!(!wide.has_layers(), "some layers haven't been popped yet"); + assert_eq!( + buffer.len(), + (width as usize) * (height as usize) * 4, + "provided width ({}) and height ({}) do not match buffer size ({})", + width, + height, + buffer.len(), + ); + + resources.before_render(); + + self.dispatcher.rasterize( + buffer, + render_mode, + width, + height, + &self.encoded_paints, + &resources.image_registry, + ); + // TODO: We need to figure something out here API-wise. At the moment, the user can + // theoretically rasterize the same `RenderContext` multiple times without resetting in-between. + // However, if glyph caching is enabled, this method call could now evict that were previously + // assumed to exist in `RenderContext`, meaning that if the user rasterizes the same `RenderContext` + // again without resetting it, some of the cached glyphs might be stale and not exist anymore. + resources.after_render(); + } + + /// Render the current context into a pixmap. + pub fn render_to_pixmap(&self, resources: &mut Resources, pixmap: &mut Pixmap) { + let width = pixmap.width(); + let height = pixmap.height(); + self.render_to_buffer( + resources, + pixmap.data_as_u8_slice_mut(), + width, + height, + self.render_settings.render_mode, + ); + } + + /// Composite the current context into a region of a pixmap. + /// + /// The context's content (sized `self.width × self.height`) is composited + /// directly to the destination pixmap starting at `(dst_x, dst_y)`. + /// If the region extends beyond the pixmap bounds, it is clipped. + /// + /// Unlike [`render_to_pixmap`](Self::render_to_pixmap), this method composites on top of + /// existing pixmap content rather than clearing it first, allowing multiple + /// renders to accumulate. + /// + /// This is useful for rendering individual elements (like glyphs) into + /// a spritesheet at specific coordinates. + /// + /// # Panics + /// + /// This method is only supported with the single-threaded dispatcher and will + /// **panic** if called on a `RenderContext` using the multi-threaded dispatcher. + pub fn composite_to_pixmap_at_offset( + &self, + resources: &Resources, + pixmap: &mut Pixmap, + dst_x: u16, + dst_y: u16, + ) { + let dst_buffer_width = pixmap.width(); + let dst_buffer_height = pixmap.height(); + self.dispatcher.composite_at_offset( + pixmap.data_as_u8_slice_mut(), + self.width, + self.height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + self.render_settings.render_mode, + &self.encoded_paints, + &resources.image_registry, + ); + } + + /// Return the width of the pixmap. + pub fn width(&self) -> u16 { + self.width + } + + /// Return the height of the pixmap. + pub fn height(&self) -> u16 { + self.height + } + + /// Return the render settings used by the `RenderContext`. + pub fn render_settings(&self) -> &RenderSettings { + &self.render_settings + } + + /// Execute a drawing operation, optionally wrapping it in a filter layer. + fn with_optional_filter(&mut self, mut f: F) + where + F: FnMut(&mut Self), + { + if let Some(filter) = self.filter.clone() { + self.push_filter_layer(filter); + f(self); + self.pop_layer(); + } else { + f(self); + } + } + + /// Take current rendering state and reset the existing state to its default. + pub fn take_current_state(&mut self) -> RenderState { + core::mem::take(&mut self.state) + } + + /// Save a copy of the current rendering state. + pub fn save_current_state(&mut self) -> RenderState { + self.state.clone() + } + + /// Restore rendering state. + pub fn restore_state(&mut self, state: RenderState) { + self.state = state; + } +} + +/// Image registry implementation. +impl Resources { + /// Register a pixmap in the image registry and return its [`ImageId`]. + pub fn register_image(&mut self, pixmap: Arc) -> ImageId { + self.image_registry.register(pixmap) + } + + /// Remove an image from the registry. + pub fn destroy_image(&mut self, id: ImageId) -> bool { + self.image_registry.destroy(id) + } + + /// Resolve an `ImageId` to its pixmap data. + pub fn resolve_image(&self, id: ImageId) -> Option> { + self.image_registry.resolve(id) + } + + /// Clear the image registry. + pub fn clear_images(&mut self) { + self.image_registry.clear(); + } +} + +/// Registry that maps opaque [`ImageId`]s to [`Pixmap`] data. +/// +/// Used by [`RenderContext`] to resolve `ImageSource::OpaqueId` at rasterization time. +#[derive(Debug, Default)] +pub(crate) struct ImageRegistry { + images: HashMap>, + next_id: u32, +} + +impl ImageRegistry { + fn register(&mut self, pixmap: Arc) -> ImageId { + let id = self.next_id; + assert!( + id < ATLAS_IMAGE_ID_BASE, + "image registry exhausted non-atlas image IDs" + ); + + self.next_id += 1; + self.images.insert(id, pixmap); + ImageId::new(id) + } + + #[cfg(feature = "text")] + pub(crate) fn register_atlas_page(&mut self, page_index: u32, pixmap: Arc) { + self.images.insert( + ImageId::new(ATLAS_IMAGE_ID_BASE + page_index).as_u32(), + pixmap, + ); + } + + pub(crate) fn destroy(&mut self, id: ImageId) -> bool { + self.images.remove(&id.as_u32()).is_some() + } + + #[cfg(feature = "text")] + pub(crate) fn destroy_atlas_page(&mut self, page_index: u32) -> bool { + self.destroy(ImageId::new(ATLAS_IMAGE_ID_BASE + page_index)) + } + + fn resolve(&self, id: ImageId) -> Option> { + self.images.get(&id.as_u32()).cloned() + } + + fn clear(&mut self) { + self.images.clear(); + self.next_id = 0; + } +} + +impl ImageResolver for ImageRegistry { + fn resolve(&self, id: ImageId) -> Option> { + self.images.get(&id.as_u32()).cloned() + } +} + +#[cfg(test)] +mod tests { + use crate::RenderContext; + #[cfg(feature = "text")] + use crate::peniko::{Blob, FontData}; + #[cfg(feature = "text")] + use alloc::sync::Arc; + #[cfg(feature = "text")] + use glifo::Glyph; + use vello_common::kurbo::{Rect, Shape}; + use vello_common::tile::Tile; + + #[test] + fn clip_overflow() { + let mut ctx = RenderContext::new(100, 100); + + for _ in 0..(usize::from(u16::MAX) + 1).div_ceil(usize::from(Tile::HEIGHT * Tile::WIDTH)) { + ctx.fill_rect(&Rect::new(0.0, 0.0, 1.0, 1.0)); + } + + ctx.push_clip_layer(&Rect::new(20.0, 20.0, 180.0, 180.0).to_path(0.1)); + ctx.pop_layer(); + ctx.flush(); + } + + #[cfg(feature = "multithreading")] + #[test] + fn multithreaded_crash_after_reset() { + use crate::{Level, RenderMode, RenderSettings}; + use vello_common::pixmap::Pixmap; + + let mut pixmap = Pixmap::new(200, 200); + let settings = RenderSettings { + level: Level::try_detect().unwrap_or(Level::baseline()), + num_threads: 1, + render_mode: RenderMode::OptimizeQuality, + }; + + let mut resources = crate::Resources::new(); + let mut ctx = RenderContext::new_with(200, 200, settings); + ctx.reset(); + ctx.fill_path(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)); + ctx.flush(); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + ctx.flush(); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + } + + #[cfg(feature = "text")] + #[test] + fn glyph_atlas_resources_are_lazy() { + const ROBOTO_FONT: &[u8] = + include_bytes!("../../../examples/assets/roboto/Roboto-Regular.ttf"); + + let font = FontData::new(Blob::new(Arc::new(ROBOTO_FONT)), 0); + let glyphs = [Glyph { + id: 1, + x: 0.0, + y: 0.0, + }]; + + let mut resources = crate::Resources::new(); + let mut ctx = RenderContext::new(100, 100); + + ctx.fill_rect(&Rect::new(0.0, 0.0, 10.0, 10.0)); + ctx.fill_path(&Rect::new(10.0, 10.0, 20.0, 20.0).to_path(0.1)); + ctx.glyph_run(&mut resources, &font) + .fill_glyphs(glyphs.into_iter()); + + assert!(resources.glyph_resources.is_none()); + + ctx.glyph_run(&mut resources, &font) + .atlas_cache(true) + .fill_glyphs(glyphs.into_iter()); + + assert!(resources.glyph_resources.is_some()); + } +} diff --git a/vendor/vello_cpu/src/text.rs b/vendor/vello_cpu/src/text.rs new file mode 100644 index 0000000000..67fb7a714b --- /dev/null +++ b/vendor/vello_cpu/src/text.rs @@ -0,0 +1,498 @@ +// Copyright 2025 the Vello Authors and the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Vello CPU glyph rendering backend. +//! +//! Provides [`GlyphAtlas`] (atlas backed by per-page [`Pixmap`]s) and the +//! [`GlyphRenderer`](glifo::GlyphRenderer) implementation for [`RenderContext`] that +//! rasterises glyphs into CPU-accessible pixel buffers. +//! +//! The key difference from the hybrid backend is that atlas pages are owned as +//! [`Arc`]s here, so the CPU renderer can read pixels directly without +//! any GPU upload step. + +use crate::render::{ATLAS_IMAGE_ID_BASE, DEFAULT_GLYPH_ATLAS_SIZE}; +use crate::{ + Image, ImageSource, PaintType, Pixmap, RenderContext, RenderMode, RenderSettings, Resources, + color, kurbo, peniko, +}; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec::Vec; +use color::palette::css::BLACK; +use core::fmt::Debug; +use core::ops::RangeInclusive; +use glifo::atlas::{ + AtlasConfig, AtlasSlot, GlyphAtlas, GlyphCacheConfig, ImageCache, PendingClearRect, +}; +use glifo::{AtlasCacher, DrawSink, GlyphRunBackend}; +use glifo::{Glyph, renderer}; +use kurbo::{Affine, BezPath, Rect}; +use peniko::BlendMode; +use peniko::color::{AlphaColor, Srgb}; +use vello_common::fearless_simd::Level; +use vello_common::paint::ImageId; + +fn atlas_page_image_id(page_index: u32) -> ImageId { + ImageId::new(ATLAS_IMAGE_ID_BASE + page_index) +} + +#[derive(Debug)] +pub(crate) struct GlyphAtlasResources { + pub(crate) glyph_atlas: GlyphAtlas, + pub(crate) image_cache: ImageCache, + pub(crate) glyph_renderer: Box, + /// One `Pixmap` per atlas page, grown on demand. + // It's a bit annoying to have this in an `Arc`, but it needs to be this way. During fine + // rasterization, we need to be able to easily clone the atlas page so that it can be shared + // across multiple threads. However, we also need to be able to mutate the pixmap to + // sync new glyphs. The way this is achieved is by calling `Arc::get_mut` when syncing + // (at this point, the pixmap isn't shared anywhere else). Before fine rasterization, we + // then share it with the image registry such that it can easily be fetched and cloned during + // fine rasterization. After that, we remove it from the image registry, such that it's uniquely + // owned again and can be mutated in the next frame. + pub(crate) pixmaps: Vec>, + /// Width of each atlas page in pixels. + page_width: u16, + /// Height of each atlas page in pixels. + page_height: u16, +} + +impl GlyphAtlasResources { + pub(crate) fn with_config( + page_width: u16, + page_height: u16, + level: Level, + render_mode: RenderMode, + eviction_config: GlyphCacheConfig, + ) -> Self { + Self { + glyph_atlas: GlyphAtlas::with_config(eviction_config), + image_cache: ImageCache::new_with_config(AtlasConfig::default()), + glyph_renderer: Box::new(RenderContext::new_with( + page_width, + page_height, + RenderSettings { + level, + num_threads: 0, + render_mode, + }, + )), + pixmaps: Vec::new(), + page_width, + page_height, + } + } + + pub(crate) fn maintain(&mut self) { + self.glyph_atlas.maintain(&mut self.image_cache); + } +} + +/// Ensure a pixmap exists for the given page, creating it if needed. +fn ensure_page( + pixmaps: &mut Vec>, + page_width: u16, + page_height: u16, + page_index: usize, +) { + while pixmaps.len() <= page_index { + pixmaps.push(Arc::new(Pixmap::new(page_width, page_height))); + } +} + +impl Resources { + pub(crate) fn prepare_glyph_cache(&mut self) { + if self.glyph_resources.is_some() { + self.sync_glyph_cache(); + } + } + + pub(crate) fn maintain_glyph_cache(&mut self) { + self.glyph_prep_cache.maintain(); + + if let Some(glyph_resources) = self.glyph_resources.as_mut() { + glyph_resources.maintain(); + let page_count = glyph_resources.pixmaps.len(); + for page_index in 0..page_count { + self.image_registry.destroy_atlas_page(page_index as u32); + } + self.clear_evicted_glyph_atlas_regions(); + } + } + + fn ensure_glyph_resources(&mut self, level: Level, render_mode: RenderMode) { + if self.glyph_resources.is_none() { + self.glyph_resources = Some(GlyphAtlasResources::with_config( + DEFAULT_GLYPH_ATLAS_SIZE, + DEFAULT_GLYPH_ATLAS_SIZE, + level, + render_mode, + GlyphCacheConfig::default(), + )); + } + } + + /// Upload all pending bitmaps, rasterize pending outline/COLR glyphs, etc. + fn sync_glyph_cache(&mut self) { + let glyph_resources = self + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist before syncing"); + + // Upload all pending bitmap glyphs to the image atlas. + for upload in glyph_resources.glyph_atlas.drain_pending_uploads() { + let page_index = upload.atlas_slot.page_index as usize; + ensure_page( + &mut glyph_resources.pixmaps, + glyph_resources.page_width, + glyph_resources.page_height, + page_index, + ); + let pixmap = Arc::get_mut(&mut glyph_resources.pixmaps[page_index]) + .expect("atlas pixmap should be uniquely owned during bitmap upload"); + copy_pixmap_to_atlas( + &upload.pixmap, + pixmap, + upload.atlas_slot.x, + upload.atlas_slot.y, + upload.atlas_slot.width, + upload.atlas_slot.height, + ); + } + + // Draw all new COLR/outline glyphs into the render context, and then composite them into the + // existing atlas page. + let glyph_renderer = glyph_resources.glyph_renderer.as_mut(); + glyph_resources + .glyph_atlas + .replay_pending_atlas_commands(|recorder| { + let page_index = recorder.page_index as usize; + ensure_page( + &mut glyph_resources.pixmaps, + glyph_resources.page_width, + glyph_resources.page_height, + page_index, + ); + let page = Arc::get_mut(&mut glyph_resources.pixmaps[page_index]) + .expect("atlas page pixmap must be uniquely owned during replay"); + + glyph_renderer.reset(); + renderer::replay_atlas_commands(&mut recorder.commands, glyph_renderer); + glyph_renderer.flush(); + glyph_renderer.composite_to_pixmap_at_offset(&Self::default(), page, 0, 0); + }); + + for (page_index, pixmap) in glyph_resources.pixmaps.iter().enumerate() { + self.image_registry + .register_atlas_page(page_index as u32, Arc::clone(pixmap)); + } + } + + fn clear_evicted_glyph_atlas_regions(&mut self) { + let glyph_resources = self + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist before clearing"); + for clear in glyph_resources.glyph_atlas.drain_pending_clear_rects() { + let pixmap = Arc::get_mut(&mut glyph_resources.pixmaps[clear.page_index as usize]) + .expect("atlas pixmap should be uniquely owned during region clearing"); + clear_pixmap_region(pixmap, clear); + } + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct CpuGlyphRunBackend<'a> { + pub ctx: &'a mut RenderContext, + pub resources: &'a mut Resources, + pub atlas_cache_enabled: bool, +} + +impl<'a> CpuGlyphRunBackend<'a> { + fn render_glyphs( + self, + run: glifo::GlyphRun<'a>, + glyphs: Glyphs, + render: impl FnOnce(&mut glifo::GlyphRunRenderer<'a, 'a, Glyphs>, &mut RenderContext), + ) where + Glyphs: Iterator + Clone, + { + let atlas_cacher = if self.atlas_cache_enabled { + self.resources.ensure_glyph_resources( + self.ctx.render_settings.level, + self.ctx.render_settings.render_mode, + ); + let glyph_resources = self + .resources + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist after initialization"); + AtlasCacher::Enabled( + &mut glyph_resources.glyph_atlas, + &mut glyph_resources.image_cache, + ) + } else { + AtlasCacher::Disabled + }; + + let mut glyph_run = run.build( + glyphs, + self.resources.glyph_prep_cache.as_mut(), + atlas_cacher, + ); + render(&mut glyph_run, self.ctx); + } +} + +impl<'a> GlyphRunBackend<'a> for CpuGlyphRunBackend<'a> { + fn atlas_cache(mut self, enabled: bool) -> Self { + self.atlas_cache_enabled = enabled; + self + } + + fn fill_glyphs(self, run: glifo::GlyphRun<'a>, glyphs: Glyphs) + where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| glyph_run.fill_glyphs(ctx)); + } + + fn stroke_glyphs(self, run: glifo::GlyphRun<'a>, glyphs: Glyphs) + where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| { + let stroke_adjustment = glyph_run.stroke_adjustment(); + let original_width = ctx.stroke().width; + ctx.stroke_mut().width *= stroke_adjustment; + glyph_run.stroke_glyphs(ctx); + ctx.stroke_mut().width = original_width; + }); + } + + fn render_decoration( + self, + run: glifo::GlyphRun<'a>, + glyphs: Glyphs, + x_range: RangeInclusive, + baseline_y: f32, + offset: f32, + size: f32, + buffer: f32, + ) where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| { + glyph_run.render_decoration(x_range, baseline_y, offset, size, buffer, ctx); + }); + } +} + +/// A glyph run builder. +pub type GlyphRunBuilder<'a> = glifo::GlyphRunBuilder<'a, CpuGlyphRunBackend<'a>>; + +/// Zero out a rectangular region in the atlas pixmap. +/// +/// Necessary because `composite_to_pixmap_at_offset` uses `SrcOver` blending, +/// so stale pixels from evicted glyphs would bleed through if not cleared. +fn clear_pixmap_region(dst: &mut Pixmap, rect: PendingClearRect) { + let dst_stride = dst.width() as usize; + let dst_data = dst.data_as_u8_slice_mut(); + let clear_width = rect.width as usize; + let clear_height = rect.height as usize; + + for y in 0..clear_height { + let row_start = ((rect.y as usize + y) * dst_stride + rect.x as usize) * 4; + let row_end = row_start + clear_width * 4; + dst_data[row_start..row_end].fill(0); + } +} + +/// Copy bitmap glyph pixels into a rectangular region of an atlas page. +fn copy_pixmap_to_atlas( + src: &Pixmap, + dst: &mut Pixmap, + dst_x: u16, + dst_y: u16, + width: u16, + height: u16, +) { + let copy_width = width as usize; + let copy_height = height as usize; + let src_stride = src.width() as usize; + let dst_stride = dst.width() as usize; + + let src_data = src.data_as_u8_slice(); + let dst_data = dst.data_as_u8_slice_mut(); + + for y in 0..copy_height { + let src_row_start = y * src_stride * 4; + let src_row_end = src_row_start + copy_width * 4; + let dst_row_start = ((dst_y as usize + y) * dst_stride + dst_x as usize) * 4; + let dst_row_end = dst_row_start + copy_width * 4; + + dst_data[dst_row_start..dst_row_end].copy_from_slice(&src_data[src_row_start..src_row_end]); + } +} + +impl DrawSink for RenderContext { + #[inline] + fn set_transform(&mut self, t: Affine) { + Self::set_transform(self, t); + } + + #[inline] + fn set_paint(&mut self, paint: glifo::AtlasPaint) { + Self::set_paint(self, paint); + } + + #[inline] + fn set_paint_transform(&mut self, t: Affine) { + Self::set_paint_transform(self, t); + } + + #[inline] + fn fill_path(&mut self, path: &BezPath) { + Self::fill_path(self, path); + } + + #[inline] + fn fill_rect(&mut self, rect: &Rect) { + Self::fill_rect(self, rect); + } + + #[inline] + fn push_clip_layer(&mut self, clip: &BezPath) { + Self::push_clip_layer(self, clip); + } + + #[inline] + fn push_clip_path(&mut self, clip: &BezPath) { + Self::push_clip_path(self, clip); + } + + #[inline] + fn push_blend_layer(&mut self, blend_mode: BlendMode) { + Self::push_blend_layer(self, blend_mode); + } + + #[inline] + fn pop_layer(&mut self) { + Self::pop_layer(self); + } + + #[inline] + fn pop_clip_path(&mut self) { + Self::pop_clip_path(self); + } + + #[inline] + fn width(&self) -> u16 { + Self::width(self) + } + + #[inline] + fn height(&self) -> u16 { + Self::height(self) + } +} + +impl glifo::GlyphRenderer for RenderContext { + type SavedState = vello_common::render_state::RenderState; + + #[inline] + fn save_state(&mut self) -> Self::SavedState { + self.save_current_state() + } + + #[inline] + fn restore_state(&mut self, state: Self::SavedState) { + Self::restore_state(self, state); + } + + #[inline] + fn stroke_path(&mut self, path: &BezPath) { + Self::stroke_path(self, path); + } + + #[inline] + fn set_paint_image(&mut self, image: Image) { + self.set_paint(image); + } + + #[inline] + fn set_tint(&mut self, tint: Option) { + Self::set_tint(self, tint); + } + + #[inline] + fn get_context_color(&self) -> AlphaColor { + let paint = self.paint().clone(); + match paint { + PaintType::Solid(s) => s, + _ => BLACK, + } + } + + #[inline] + fn atlas_image_source(&self, atlas_slot: &AtlasSlot) -> ImageSource { + ImageSource::opaque_id(atlas_page_image_id(atlas_slot.page_index)) + } + + #[inline] + fn atlas_paint_transform(&self, atlas_slot: &AtlasSlot) -> Affine { + Affine::translate((-(atlas_slot.x as f64), -(atlas_slot.y as f64))) + } +} + +/// Debug utilities for visualizing glyph bounds during rasterization. +#[cfg(debug_assertions)] +#[allow( + dead_code, + unreachable_pub, + clippy::trivially_copy_pass_by_ref, + reason = "debug-only utilities called manually during development" +)] +mod debug { + use core::sync::atomic::{AtomicUsize, Ordering}; + + use crate::RenderContext; + use crate::kurbo::{Affine, Rect}; + use crate::peniko; + use glifo::atlas::RasterMetrics; + + static COLOR_INDEX: AtomicUsize = AtomicUsize::new(0); + + /// Twelve semi-transparent rotating colours for distinguishing adjacent + /// glyph bounds. Each call to [`fill_glyph_bounds`] advances the index. + const COLORS: [peniko::Color; 12] = [ + peniko::Color::new([1.0, 0.0, 0.0, 0.5]), // Red + peniko::Color::new([0.0, 1.0, 0.0, 0.5]), // Green + peniko::Color::new([0.0, 0.0, 1.0, 0.5]), // Blue + peniko::Color::new([1.0, 1.0, 0.0, 0.5]), // Yellow + peniko::Color::new([1.0, 0.0, 1.0, 0.5]), // Magenta + peniko::Color::new([0.0, 1.0, 1.0, 0.5]), // Cyan + peniko::Color::new([1.0, 0.5, 0.0, 0.5]), // Orange + peniko::Color::new([0.5, 0.0, 1.0, 0.5]), // Purple + peniko::Color::new([0.0, 1.0, 0.5, 0.5]), // Mint + peniko::Color::new([1.0, 0.5, 0.5, 0.5]), // Pink + peniko::Color::new([0.5, 1.0, 0.5, 0.5]), // Light green + peniko::Color::new([0.5, 0.5, 1.0, 0.5]), // Light blue + ]; + + /// Fill the glyph's pixel bounds with the next rotating debug colour. + /// Call before rendering the actual glyph content to visualise the extent. + pub fn fill_glyph_bounds(renderer: &mut RenderContext, raster_metrics: &RasterMetrics) { + let idx = COLOR_INDEX.fetch_add(1, Ordering::Relaxed) % COLORS.len(); + renderer.set_transform(Affine::IDENTITY); + renderer.set_paint(COLORS[idx]); + renderer.fill_rect(&Rect::new( + 0.0, + 0.0, + raster_metrics.width as f64, + raster_metrics.height as f64, + )); + } +} diff --git a/vendor/vello_cpu/src/text_debug.rs b/vendor/vello_cpu/src/text_debug.rs new file mode 100644 index 0000000000..aef708a4eb --- /dev/null +++ b/vendor/vello_cpu/src/text_debug.rs @@ -0,0 +1,108 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Debug helpers for glyph atlas and CPU text resources. + +#![allow(dead_code, reason = "only used for debugging purposes")] + +#[cfg(feature = "png")] +use crate::Pixmap; +#[cfg(feature = "png")] +use crate::render::Resources; +use crate::text::GlyphAtlasResources; +#[cfg(feature = "png")] +use alloc::format; +use glifo::GlyphCacheKey; +use glifo::atlas::GlyphCacheStats; + +#[cfg(feature = "png")] +impl GlyphAtlasResources { + /// Save all atlas pages to PNG files with a custom path prefix. + /// + /// Files are saved as `{path_prefix}_atlas_page_{index}.png`. + pub(crate) fn save_atlas_pages_to(&self, path_prefix: &str) { + for (i, pixmap) in self.pixmaps.iter().enumerate() { + let path = format!("{path_prefix}_atlas_page_{i}.png"); + let _ = save_pixmap_to_png(pixmap, std::path::Path::new(&path)); + } + } + + /// Save all atlas pages to PNG files for debugging. + /// + /// Files are saved to `examples/_output/vello_cpu_atlas_page_{index}.png`. + pub(crate) fn save_atlas_pages(&self) { + for (i, pixmap) in self.pixmaps.iter().enumerate() { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.pop(); // up from vello_cpu to sparse_strips + path.pop(); // up from sparse_strips to workspace root + path.push("examples"); + path.push("_output"); + let _ = std::fs::create_dir_all(&path); + path.push(format!("vello_cpu_atlas_page_{i}.png")); + let _ = save_pixmap_to_png(pixmap, &path); + } + } +} + +impl GlyphAtlasResources { + /// Get detailed statistics about cached glyphs. + pub(crate) fn stats(&self) -> GlyphCacheStats { + self.glyph_atlas.stats(self.pixmaps.len()) + } + + /// Log detailed atlas statistics at info level. + pub(crate) fn log_atlas_stats(&self) { + self.glyph_atlas.log_atlas_stats(self.pixmaps.len()); + } + + /// Returns all cached glyph keys (for debugging). + pub(crate) fn all_keys(&self) -> impl Iterator { + self.glyph_atlas.all_keys() + } + + /// Log all cached keys grouped by glyph ID at info level. + pub(crate) fn log_keys_grouped(&self) { + self.glyph_atlas.log_keys_grouped(); + } +} + +#[cfg(feature = "png")] +impl Resources { + pub(crate) fn save_glyph_atlas_pages(&self) { + if let Some(glyph_resources) = &self.glyph_resources { + glyph_resources.save_atlas_pages(); + } + } + + pub(crate) fn save_glyph_atlas_pages_to(&self, path_prefix: &str) { + if let Some(glyph_resources) = &self.glyph_resources { + glyph_resources.save_atlas_pages_to(path_prefix); + } + } +} + +/// Save a pixmap to a PNG file (diagnostic utility). +#[cfg(feature = "png")] +pub(crate) fn save_pixmap_to_png(pixmap: &Pixmap, path: &std::path::Path) -> std::io::Result<()> { + use std::fs::File; + use std::io::BufWriter; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let file = File::create(path)?; + let w = BufWriter::new(file); + + let mut encoder = png::Encoder::new(w, pixmap.width() as u32, pixmap.height() as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + + let mut writer = encoder.write_header().map_err(std::io::Error::other)?; + + writer + .write_image_data(pixmap.data_as_u8_slice()) + .map_err(std::io::Error::other)?; + + Ok(()) +} diff --git a/vendor/vello_cpu/src/util.rs b/vendor/vello_cpu/src/util.rs new file mode 100644 index 0000000000..b0e692772d --- /dev/null +++ b/vendor/vello_cpu/src/util.rs @@ -0,0 +1,112 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko::ImageQuality; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Simd, SimdBase, f32x4, u8x32}; +use vello_common::math::FloatExt; +use vello_common::util::Div255Ext; + +pub(crate) mod scalar { + /// Perform an approximate division by 255. + /// + /// There are three reasons for having this method. + /// 1) Divisions are slower than shifting + adding, and the compiler does not seem to replace + /// divisions by 255 with an equivalent (this was verified by benchmarking; doing / 255 was + /// significantly slower). + /// 2) Integer divisions are usually not available in SIMD, so this provides a good baseline + /// implementation. + /// 3) There are two options for performing the division: One is to perform the division + /// in a way that completely preserves the rounding semantics of a integer division by + /// 255. This could be achieved using the implementation `(val + 1 + (val >> 8)) >> 8`. + /// The second approach (used here) has slightly different rounding behavior to a + /// normal division by 255, but is much faster (see ) + /// and therefore preferable for the high-performance pipeline. + /// + /// Four properties worth mentioning: + /// - This actually calculates the ceiling of `val / 256`. + /// - Within the allowed range for `val`, rounding errors do not appear for values divisible by 255, i.e. any call `div_255(val * 255)` will always yield `val`. + /// - If there is a discrepancy, this division will always yield a value 1 higher than the original. + /// - This holds for values of `val` up to and including `65279`. You should not call this function with higher values. + #[inline(always)] + pub(crate) const fn div_255(val: u16) -> u16 { + debug_assert!( + val < 65280, + "the properties of `div_255` do not hold for values of `65280` or greater" + ); + (val + 255) >> 8 + } + + #[cfg(test)] + mod tests { + use crate::util::scalar::div_255; + + #[test] + fn div_255_properties() { + for i in 0_u16..256 * 255 { + let expected = i / 255; + let actual = div_255(i); + + assert!( + expected <= actual, + "In case of a discrepancy, the division should yield a value higher than the original." + ); + + let diff = expected.abs_diff(actual); + assert!(diff <= 1, "Rounding error shouldn't be higher than 1."); + + if i % 255 == 0 { + assert_eq!(diff, 0, "Division should be accurate for multiples of 255."); + } + } + } + } +} + +pub(crate) trait NormalizedMulExt { + fn normalized_mul(self, other: Self) -> Self; +} + +impl NormalizedMulExt for u8x32 { + #[inline(always)] + fn normalized_mul(self, other: Self) -> Self { + let divided = (self.simd.widen_u8x32(self) * other.simd.widen_u8x32(other)).div_255(); + self.simd.narrow_u16x32(divided) + } +} + +pub(crate) trait EncodedImageExt { + fn has_skew(&self) -> bool; + fn nearest_neighbor(&self) -> bool; +} + +impl EncodedImageExt for EncodedImage { + fn has_skew(&self) -> bool { + !(self.x_advance.y as f32).is_nearly_zero() || !(self.y_advance.x as f32).is_nearly_zero() + } + + fn nearest_neighbor(&self) -> bool { + self.sampler.quality == ImageQuality::Low + } +} + +pub(crate) trait Premultiply { + fn premultiply(self, alphas: Self) -> Self; + fn unpremultiply(self, alphas: Self) -> Self; +} + +impl Premultiply for f32x4 { + #[inline(always)] + fn premultiply(self, alphas: Self) -> Self { + self * alphas + } + + #[inline(always)] + fn unpremultiply(self, alphas: Self) -> Self { + let zero = Self::splat(alphas.simd, 0.0); + let divided = self / alphas; + + self.simd + .select_f32x4(self.simd.simd_eq_f32x4(alphas, zero), zero, divided) + } +} From 11d9b4cda91e9cb704c1a3800d62bbf956840144 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 14:49:50 -0400 Subject: [PATCH 60/75] test(render): don't claim which vello_cpu pipeline the Luminosity guard runs in The previous commit's message and this header said the Luminosity ramp passes because pages use the u8 pipeline. That wasn't established; the case simply renders correctly here, so the test is a guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/tests/it/nonseparable_gray.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/render/tests/it/nonseparable_gray.rs b/crates/render/tests/it/nonseparable_gray.rs index 5a3a3d7a28..34847f9520 100644 --- a/crates/render/tests/it/nonseparable_gray.rs +++ b/crates/render/tests/it/nonseparable_gray.rs @@ -1,8 +1,7 @@ //! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's //! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black -//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going black in -//! vello_cpu's f32 pipeline; pages render through the u8 pipeline, where that case already holds, -//! so its test here guards rather than reproduces. +//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going wrong; that +//! case already renders correctly here, so its test guards rather than reproduces. use std::sync::Arc; From 7555be3c7ef1d2dc22ae1327791552812603d520 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 15:48:56 -0400 Subject: [PATCH 61/75] Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#5205) The script already validated CARGO_BUILD_TARGET and named the archive after the arch, but cargo build ignored it, so a cross build compiled for the host. Pass --target, resolve host-tuple to the build host's triple first, and skip the --version smoke run for any CARGO_BUILD_TARGET build (an aarch64 binary cannot run on the x86_64 VM). --- packaging/freebsd/package.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e22bca4eae..0533be9010 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -31,6 +31,10 @@ release_arch() { } # The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to # aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +# host-tuple means the build host's triple, as rustc names it. +if [[ "${CARGO_BUILD_TARGET:-}" == host-tuple ]]; then + CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +fi case "${CARGO_BUILD_TARGET:-}" in "") ARCH="$(release_arch "$(uname -m)")" ;; aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; @@ -47,7 +51,7 @@ BASENAME="pdfcraft-$VERSION-freebsd-$ARCH" echo "==> PdfCraft $VERSION for FreeBSD $ARCH" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli) + (cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} -p pdfcraft -p pdfcraft-cli) fi # Cargo puts a cross build under target//release. BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" @@ -70,7 +74,7 @@ copy_docs "$STAGE/share/doc/pdfcraft" # which has to run here: build it for this machine, not for CARGO_BUILD_TARGET. (unset CARGO_BUILD_TARGET; stage_models "$STAGE/share/pdfcraft/models") -if [ "$ARCH" = "$HOST_ARCH" ]; then +if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then "$STAGE/bin/pdfcraft" --version else # They cannot run here, so check what they are instead: executables, not libraries or objects. @@ -82,7 +86,7 @@ else *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; esac done - echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" + echo "skipping --version: a CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET build does not run on this $HOST_ARCH machine" fi mkdir -p "$DIST" tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" From 40c22e3ea58e26f2450d88436b7ed24b88a37862 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:39:06 +0200 Subject: [PATCH 62/75] Export Data as CSV keeps formula-like cells as text, like the merge report (#2524) Export Data (CSV) wrote field names and values as they are, so a value from a received form that starts with = or @ (or + or - when it is not a number) runs as a formula when the CSV is opened in a spreadsheet. It now uses the rule merge_csv got in #4101, moved unchanged into runs_as_formula, so the merge report's output is the same. Import Data takes the added ' off again, so Export Data then Import Data gives back every value unchanged. --- crates/xfdf/README.md | 4 +++- crates/xfdf/src/lib.rs | 48 +++++++++++++++++++++++++++++++--------- crates/xfdf/src/tests.rs | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index c137aabd93..d286a457c3 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -40,7 +40,9 @@ including CDATA and character references. Nested-element and XFA interpretation CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Automatic detection of externally authored, ambiguous -tables remains heuristic. +tables remains heuristic. Both write a cell that a spreadsheet would run as a formula (`=` or `@`, +or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that +export added, so exported data imports back unchanged. Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ba8cb6f7f7..dec32c7418 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -112,7 +112,11 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + // A formula-like cell gets a leading `'`, as in `merge_csv`; import takes it off again. + let q = |s: &str| { + let s = if csv_guarded(s) { format!("'{s}") } else { s.to_string() }; + if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } + }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -184,15 +188,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let literal = |s: &str| { - let t = s.trim_start(); - let formula = match t.chars().next() { - Some('=' | '@') => true, - Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), - _ => false, - }; - if formula { format!("'{s}") } else { s.to_string() } - }; + let literal = |s: &str| if runs_as_formula(s) { format!("'{s}") } else { s.to_string() }; let q = |s: &str| { let s = literal(s); if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } @@ -207,6 +203,32 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { out } +/// Whether a spreadsheet program runs CSV cell `s` as a formula: after any leading whitespace it +/// starts with `=` or `@`, or with `+` or `-` and is not a plain number. +fn runs_as_formula(s: &str) -> bool { + let t = s.trim_start(); + match t.chars().next() { + Some('=' | '@') => true, + Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), + _ => false, + } +} + +/// Whether Export Data writes CSV cell `s` with a leading `'`: it runs as a formula, or it already +/// starts with `'` in front of one. That second case gets one more `'`, so [`csv_text`] can take +/// exactly one off on import and every value comes back unchanged. +fn csv_guarded(s: &str) -> bool { + runs_as_formula(s.trim_start_matches('\'')) +} + +/// A CSV cell's text without the `'` that Export Data added (see [`csv_guarded`]). +fn csv_text(cell: String) -> String { + match cell.strip_prefix('\'') { + Some(rest) if csv_guarded(rest) => rest.to_string(), + _ => cell, + } +} + // Inspect the first logical record: tabs within quoted CSV cells are data. A quote // in the middle of an unquoted TSV name stays literal, as in the TSV reader. fn has_tab_delimiter(text: &str) -> bool { @@ -287,7 +309,11 @@ fn import_table(doc: &mut Document, text: &str) -> Result { let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) }; // Text exported from a one-field form has no tab to show for it (#1321). CSV quotes a // value with a comma, so one name over several unquoted cells is that text, not CSV. - if head.len() == 1 && row.len() > 1 { tab_delimited(t)? } else { head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() } + if head.len() == 1 && row.len() > 1 { + tab_delimited(t)? + } else { + head.into_iter().zip(row).map(|(k, v)| (csv_text(k), vec![csv_text(v)])).collect() + } }; apply_values(doc, &values, &mut report); Ok(report) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 52a4a5b86d..d2110d3dae 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -623,6 +623,45 @@ fn csv_exports_preserve_carriage_returns_in_names_and_values() { } } +/// Export Data as CSV writes a name or value that a spreadsheet would run as a formula with a +/// leading `'`, by the same rule as Merge Data Files, and Import Data gives it back unchanged. +#[test] +fn exported_csv_cells_never_start_a_formula_and_import_back_unchanged() { + for (name, value, expected) in [ + ("Name", "=1+1", "Name\n'=1+1\n"), + ("Name", "@SUM(A1:A2)", "Name\n'@SUM(A1:A2)\n"), + ("Name", "+1 555 0100", "Name\n'+1 555 0100\n"), + ("Name", "-A1", "Name\n'-A1\n"), + ("Name", " =1+1", "Name\n' =1+1\n"), + ("Name", "\t=1+1", "Name\n\"'\t=1+1\"\n"), + ("Name", "\r=1+1", "Name\n\"'\r=1+1\"\n"), + ("Name", "=1,2", "Name\n\"'=1,2\"\n"), + ("Name", "'=1+1", "Name\n''=1+1\n"), + ("Name", "-12.5", "Name\n-12.5\n"), + ("Name", "+1", "Name\n+1\n"), + ("Name", "'-5", "Name\n'-5\n"), + ("Name", "'plain", "Name\n'plain\n"), + ("Name", "a=b", "Name\na=b\n"), + ("=Name", "Ada", "'=Name\nAda\n"), + ] { + let mut src = one_field(); + let field = fields(&src)[0].obj; + src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap(); + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let csv = export_data(&src, Format::Csv); + assert_eq!(csv, expected, "{value:?}"); + + let mut dst = Document::open(source).unwrap(); + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + let report = import(&mut dst, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 1, "{value:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + assert_eq!(values(&dst), [(name.to_string(), vec![value.to_string()])]); + } +} + /// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the /// value used to split it and only the text before the comma was imported. #[test] From 03996b10258288e723c4e07334c9cd1bae1f68a8 Mon Sep 17 00:00:00 2001 From: Lucas Sampaio Dias Date: Wed, 28 Oct 2026 18:41:29 -0300 Subject: [PATCH 63/75] Improve reading-colour preferences and add a settings shortcut (#2627) * M3: improve reading-colour preferences and add settings shortcut * M3: clarify the disabled reading-colour preview * M3: adapt rebased preferences to current UI dependencies --- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/workflows/js_ui.rs | 147 +++++++++--------- crates/ui-egui/tests/it/preferences_layout.rs | 24 +-- crates/ui-egui/tests/it/reading_colors.rs | 54 +++++-- crates/ui-egui/tests/it/theme_button.rs | 14 ++ crates/ui-egui/tests/it/ui.rs | 8 +- parity/acrobat-features.toml | 4 +- 8 files changed, 154 insertions(+), 110 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a46f2173c8..aa6233c133 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -69,10 +69,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.active = None; app.combine_tab.focused = false; } - // Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs. + // Reserve the three icon buttons, Discord and their gaps outside the scrolling tabs. let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) - + 2.0 * button - + 50.0 + + 3.0 * button + + 54.0 + window_controls::width(&controls.right); // Open stays in sight after the tabs: its icon, text and padding, and the gaps around it. let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0; @@ -287,6 +287,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); ui.add_space(window_controls::SIDE_GAP - 4.0); } + if icons::button(ui, "settings", button, false, tl!("Preferences")).clicked() { + app.execute("app.preferences"); + } let (icon, label) = match app.theme_preference { ThemePreference::System => ("monitor", tl!("Use system setting")), ThemePreference::Light => ("sun", tl!("Light gray")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c8bef1cd..f58a7d972f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -254,6 +254,10 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { + // The bundled UI fonts do not all cover the menu's mathematical ellipsis. + if english == "\u{22ef}" { + return "...".to_owned(); + } tl!(english).to_owned() } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 0ebdbb07a6..4296487e4c 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -265,18 +265,6 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok close } -/// The label for a [`pdfcraft_render::ReadingColors`] mode name (`mode_name`). -fn reading_mode_label(mode: &str) -> &'static str { - match mode { - "black-on-white" => "Black on white", - "white-on-black" => "White on black", - "yellow-on-black" => "Yellow on black", - "green-on-black" => "Green on black", - "custom" => "Custom", - _ => "Off", - } -} - /// The height the Preferences dialog keeps below its scrolling settings for the OK row: the /// button, and the space above it. pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; @@ -394,6 +382,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); + reading_colours_section(ui, app, t); + ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0))); ui.horizontal(|ui| { ui.label(tl!("Default workspace mode")); @@ -478,70 +468,6 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme.")); } ui.add_space(8.0); - // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are - // remapped while rendering, and the page clears to the background colour. The preference - // is app-wide: every open document follows it, and documents open with it. - ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); - ui.horizontal(|ui| { - let label = ui.label(tl!("Replace document colours")); - let mode = app.reading_colors.mode_name(); - craft_ui::ComboBox::from_id_salt("reading-colors") - .selected_text(tl!(reading_mode_label(mode))) - .show_ui(ui, |ui| { - for (m, label) in [ - ("off", "Off"), - ("black-on-white", "Black on white"), - ("white-on-black", "White on black"), - ("yellow-on-black", "Yellow on black"), - ("green-on-black", "Green on black"), - ("custom", "Custom"), - ] { - if ui.selectable_label(mode == m, tl!(label)).clicked() { - match m { - "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), - "custom" => { - // Custom starts from the colours already chosen, or black on white. - let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { - pdfcraft_render::ReadingColors { params: Some(params) } - }); - app.set_reading_colors(colors); - } - // `parse` knows these names; a preset comes with its own flags. - preset => { - if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { - app.set_reading_colors(colors); - } - } - } - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - }); - if app.reading_colors.params.is_some() { - let mut changed = false; - if let Some(params) = app.reading_colors.params.as_mut() { - ui.horizontal(|ui| { - let text = ui.label(tl!("Text")); - changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); - let background = ui.label(tl!("Page background")); - changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); - }); - if ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed() { - changed = true; - } - if ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed() { - changed = true; - } - } - if changed { - app.set_reading_colors(app.reading_colors); - } - } - ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); - ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); ui.horizontal(|ui| { @@ -679,6 +605,75 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ); } +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are + // remapped while rendering, and the page clears to the background colour. The preference + // is app-wide: every open document follows it, and documents open with it. + ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); + let mode = app.reading_colors.mode_name(); + ui.horizontal_wrapped(|ui| { + for (name, label, colors) in [ + ("off", "Off", pdfcraft_render::ReadingColors::default()), + ("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE), + ("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK), + ("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK), + ("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK), + ] { + let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams { + text: [0, 0, 0], + background: [255, 255, 255], + only_black: false, + line_art: true, + }); + ui.vertical(|ui| { + ui.set_width(104.0); + let sample = egui::RichText::new("Aa").size(22.0).color(egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2])); + let button = egui::Button::new(sample) + .fill(egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2])) + .min_size(egui::vec2(94.0, 44.0)) + .stroke(egui::Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border })); + let response = ui.add(button); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label))); + if response.clicked() { + app.set_reading_colors(colors); + } + ui.label(tl!(label)); + }); + } + }); + ui.add_space(8.0); + if let Some(params) = app.reading_colors.params { + let text = egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]); + let background = egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]); + egui::Frame::new().fill(background).inner_margin(16.0).show(ui, |ui| { + ui.set_min_width((ui.available_width() - 32.0).max(0.0)); + ui.label(egui::RichText::new(tl!("Reading colours")).size(18.0).color(text)); + ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text)); + }); + } else { + ui.label(tl!("Original")); + } + egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| { + let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE }; + let mut changed = false; + if let Some(params) = colors.params.as_mut() { + ui.horizontal(|ui| { + let text = ui.label(tl!("Text")); + changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); + let background = ui.label(tl!("Page background")); + changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); + }); + changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed(); + changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed(); + } + if changed { + app.set_reading_colors(colors); + } + }); + ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); + ui.add_space(8.0); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 3b8d41ec06..1ebe06b7a0 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -29,7 +29,7 @@ fn ok_is_on_screen_without_scrolling_at_any_window_height() { let h = harness(height); let ok = h.get_by_label("OK").rect(); assert!(on_screen(ok, height), "{height}: OK at {ok:?} is off the {height} pt window"); - let title = h.get_by_label("Preferences").rect(); + let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(); assert!(on_screen(title, height), "{height}: the title at {title:?} is off the window"); // The OK row sits below the scrolled settings, never over them: the settings' last visible // row ends above it. The title is above everything. @@ -43,13 +43,13 @@ fn ok_closes_the_dialog_in_a_short_window_and_the_settings_still_scroll() { // A setting far down the list starts out below the visible settings; scrolling brings it in // between the title and the OK row, and OK did not move. let ok_before = h.get_by_label("OK").rect(); - let title = h.get_by_label("Preferences").rect(); - let far = h.get_by_label_contains("Reading colours").rect(); - assert!(far.min.y >= ok_before.min.y, "Reading colours starts out of sight: {far:?} vs OK {ok_before:?}"); - h.get_by_label_contains("Reading colours").scroll_to_me(); + let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(); + let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect(); + assert!(far.min.y >= ok_before.min.y, "JavaScript setting starts out of sight: {far:?} vs OK {ok_before:?}"); + h.get_by_label_contains("Enable Acrobat JavaScript").scroll_to_me(); h.run_steps(4); - let far = h.get_by_label_contains("Reading colours").rect(); - assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "Reading colours scrolled in between the title and OK: {far:?}"); + let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect(); + assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "JavaScript setting scrolled in between the title and OK: {far:?}"); assert_eq!(h.get_by_label("OK").rect(), ok_before, "OK stays put while the settings scroll"); assert!(h.state().dialog.is_some()); h.get_by_label("OK").click(); @@ -64,9 +64,15 @@ fn the_dialog_opens_at_its_full_size_and_does_not_grow_afterwards() { // in its first frames landed on controls that were still moving. for height in [260.0, 560.0, 900.0] { let mut h = harness(height); - let settled = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect()); + let settled = ( + h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(), + h.get_by_label("OK").rect(), + ); h.run_steps(8); - let later = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect()); + let later = ( + h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(), + h.get_by_label("OK").rect(), + ); assert_eq!(settled, later, "{height}: the dialog moved after it had been shown for six frames"); } // And it is in place within a few frames of opening, for the first click: a frame or two of diff --git a/crates/ui-egui/tests/it/reading_colors.rs b/crates/ui-egui/tests/it/reading_colors.rs index 398a85e8e0..ac48d89d05 100644 --- a/crates/ui-egui/tests/it/reading_colors.rs +++ b/crates/ui-egui/tests/it/reading_colors.rs @@ -100,17 +100,6 @@ fn png() -> Vec { out } -/// The Reading colours combo of the preferences dialog: it shows the chosen mode as its value, -/// and no other combo in the dialog shows `Off` while the setting is off. -fn combo(h: &mut Harness<'static, PdfCraftApp>) { - // The preferences dialog grew taller than the window, which leaves this combo below the - // fold where a click lands outside the dialog and dismisses it; scroll it into view and - // let the scroll settle before clicking. - h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).scroll_to_me(); - h.run_steps(4); - h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).click(); -} - #[test] fn reading_colours_preference_round_trips() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -122,11 +111,9 @@ fn reading_colours_preference_round_trips() { }); h.run_steps(4); assert_eq!(h.state().reading_colors, ReadingColors::default()); - // Switch the preset to white-on-black in the dialog's Reading colours combo. The open - // document keeps repainting, so run a fixed number of steps and let the popup settle. - combo(&mut h); - h.run_steps(20); - h.get_by_label("White on black").click(); + h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).scroll_to_me(); + h.run_steps(4); + h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).click(); h.run_steps(2); assert_eq!(h.state().reading_colors, ReadingColors::WHITE_ON_BLACK); // The preference is app-wide: the already open document follows it too. @@ -151,6 +138,41 @@ fn reading_colours_preference_round_trips() { assert_eq!(fresh.reading_colors, ReadingColors::default()); } +#[test] +fn reading_presets_follow_appearance_and_do_not_change_the_interface_theme() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_option("dialog", "preferences").unwrap(); + app + }); + h.run_steps(6); + assert!(h.query_by_label("Original").is_some()); + let theme = h.state().theme_preference; + let appearance = h.get_by_label("Light theme").rect(); + let preset = h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).rect(); + let documents = h.get_by_label("Documents and view").rect(); + assert!(appearance.bottom() < preset.top() && preset.bottom() < documents.top()); + for (label, expected) in [ + ("White on black", ReadingColors::WHITE_ON_BLACK), + ("Yellow on black", ReadingColors::YELLOW_ON_BLACK), + ("Green on black", ReadingColors::GREEN_ON_BLACK), + ("Black on white", ReadingColors::BLACK_ON_WHITE), + ("Off", ReadingColors::default()), + ] { + h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).scroll_to_me(); + h.run_steps(4); + h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).click(); + h.run_steps(4); + assert_eq!(h.state().reading_colors, expected); + assert_eq!(h.state().theme_preference, theme); + assert_eq!(h.query_by_label("Original").is_some(), expected.params.is_none()); + } + if let Ok(path) = std::env::var("PDFCRAFT_READING_SHOT") { + h.render().unwrap().save(path).unwrap(); + } +} + #[test] fn reading_colours_seed_on_open() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { diff --git a/crates/ui-egui/tests/it/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs index 758aa1a277..6568fca156 100644 --- a/crates/ui-egui/tests/it/theme_button.rs +++ b/crates/ui-egui/tests/it/theme_button.rs @@ -19,6 +19,20 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String { format!("{:?}", h.state().theme_preference) } +#[test] +fn preferences_gear_is_right_of_theme_and_opens_preferences() { + let mut h = harness(); + let theme = h.get_by_label("Display theme: Use system setting").rect(); + let gear = h.get_by_label("Preferences").rect(); + assert!(gear.left() >= theme.right()); + h.get_by_label("Preferences").click(); + h.run_steps(6); + assert!(h.query_by_label("Appearance mode").is_some()); + assert!(h.query_by_label("Light theme").is_some()); + assert!(h.query_by_label("Dark theme").is_some()); + assert_eq!(preference(&h), "System"); +} + #[test] fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index cd5ce8dbb1..69be82e7a7 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -706,11 +706,11 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { app }); h.run_steps(4); - let last = h.get_by_label("narrow-11.pdf").rect(); + let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect(); assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}"); h.state_mut().active = Some(0); h.run_steps(4); - let first = h.get_by_label("narrow-00.pdf").rect(); + let first = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-00.pdf")).rect(); assert!(first.center().x > 28.0 && first.right() < width - 100.0); h.event(egui::Event::PointerMoved(first.center())); for _ in 0..8 { @@ -723,9 +723,9 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { }); h.run_steps(8); } - let last = h.get_by_label("narrow-11.pdf").rect(); + let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect(); assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}"); - h.get_by_label("narrow-11.pdf").click(); + h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).click(); h.run_steps(4); assert_eq!(h.state().active, Some(11)); assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ac06fdd4..2a17a9f02e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9180,8 +9180,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background", "crates/ui-egui/tests/it/reading_colors.rs::reading_presets_follow_appearance_and_do_not_change_the_interface_theme", "crates/ui-egui/tests/it/theme_button.rs::preferences_gear_is_right_of_theme_and_opens_preferences"] +notes = "View only, off by default: Preferences ▸ Reading colours, directly below Appearance, offers colour samples and a live preview; the gear beside the theme button opens Preferences. Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" From 21f6c29d3e4ab3f79d81aad86412584f3efc3d36 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:46:11 -0400 Subject: [PATCH 64/75] M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294) (#4306) * M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294) * M6: a form script message waits for a notice the toast already shows * M6: form script messages past the queue's room are counted and reported * M6: a sentence break in the merged parity note * M6: an object that names no parameter is a message's text, as a String object or a caught error * M6: the count of left-out script messages is said again if another notice replaces it before its time * M6: a full stop between two sentences of the FormCalc parity note * chore: re-run CI (a frame-budget timing test missed on a shared runner) --- crates/automation/src/a11y/form_tools.rs | 13 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 21 ++- crates/engine/src/js.rs | 9 +- crates/engine/src/tests.rs | 191 +++++++++++++--------- crates/engine/src/xfa.rs | 6 +- crates/js/src/formcalc.rs | 2 +- crates/js/src/formcalc/eval.rs | 16 +- crates/js/src/globals.rs | 74 ++++++++- crates/js/src/host.rs | 12 +- crates/js/src/lib.rs | 3 +- crates/js/src/model.rs | 108 ++++++++++++- crates/js/src/tests.rs | 192 +++++++++++++++++++++-- crates/js/src/xfa.rs | 2 +- crates/js/src/xfa/globals.rs | 21 ++- crates/js/src/xfa/host.rs | 4 +- crates/js/src/xfa/model.rs | 8 +- crates/ui-egui/src/app.rs | 9 ++ crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/widgets.rs | 38 ++++- crates/ui-egui/src/workflows/js_ui.rs | 57 ++++++- crates/ui-egui/tests/it/javascript.rs | 75 +++++++++ parity/acrobat-features.toml | 12 +- 23 files changed, 741 insertions(+), 145 deletions(-) diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 7272a8d669..2420a44372 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { } } +/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), +/// icon and buttons. +fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { + json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) +} + impl Automation { + /// `js_run`: run `script` in the document, or as button `field`'s script, and report its + /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok( - json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), - ) + let alerts: Vec = o.alerts.iter().map(alert_json).collect(); + Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result })) } pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b4ac8873b..78a6805bb3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1135,7 +1135,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6891cad3f1..fb904bacfe 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6513,6 +6513,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() { } } +/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console +/// lines, every message with its details, and requests. #[test] fn javascript_through_tools() { let dir = workdir("js"); @@ -6546,10 +6548,17 @@ fn javascript_through_tools() { let r = ok( &mut a, "js_run", - json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }), + json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }), ); assert_eq!(r["console"][0], "2500"); - assert_eq!(r["alerts"][0], "ok"); + // Every message, in order, with its title, icon and buttons. + assert_eq!( + r["alerts"], + json!([ + { "message": "ok", "title": null, "icon": "error", "buttons": "ok" }, + { "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" }, + ]) + ); assert_eq!(r["requests"][0]["page"], 2); let f = ok(&mut a, "form_fields", json!({ "doc": doc })); let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); @@ -7470,6 +7479,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { check(&mut a, doc); } +/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo, +/// and `js_run` reports their messages. #[test] fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); @@ -7498,7 +7509,7 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { ok(&mut a, "edit_undo", json!({ "doc": doc })); assert!(field(&mut a, "amount_2").is_none()); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" })); - assert_eq!(r["alerts"], json!(["Hello 4"])); + assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }])); } #[test] @@ -7511,7 +7522,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() { ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback})); let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"})); assert!(out["error"].is_null(), "{out}"); - assert_eq!(out["alerts"], json!(["Console message"])); + assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }])); ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); ok(&mut a, "doc_close", json!({"doc":doc})); let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); @@ -7521,7 +7532,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() { assert_eq!(out["alerts"], json!([])); let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"})); assert!(out["error"].is_null(), "{out}"); - assert_eq!(out["alerts"], json!(["Hello 2"])); + assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }])); ok(&mut a, "xfa_to_static", json!({"doc":doc})); let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); assert_eq!(out["alerts"].as_array().unwrap().len(), 1); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 7f94971cc1..d1ee5c15b0 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -7,14 +7,15 @@ //! off, scripts don't run and AF calls keep working natively. use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; -pub use pdfcraft_js::{Limits, Outcome, Panel, Request}; +pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request}; use crate::{DocId, Edit, EditError, Session}; /// What scripts produced for the viewer since it last looked. #[derive(Clone, Debug, Default, PartialEq)] pub struct JsOutput { - pub alerts: Vec, + /// The messages scripts showed, in order, with their titles, icons and buttons. + pub alerts: Vec, pub console: Vec, pub requests: Vec, /// Script errors (as Acrobat's console shows them). @@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and + /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { let states: Vec<_> = fields .iter() @@ -218,7 +221,7 @@ impl Scripts for JsRunner { rc: o.rc, value: o.value.clone(), changes: changes(&states, &o.changed), - message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(), + message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 14a61b2633..84e295d364 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4808,6 +4808,8 @@ fn scripted_form_with(helpers: &str) -> Vec { out } +/// AcroForm field scripts validate, calculate and format through edits, and a button script's +/// changes, messages and requests come back. #[test] fn form_javascript_validates_calculates_and_formats() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -4826,7 +4828,7 @@ fn form_javascript_validates_calculates_and_formats() { // A button script changes fields in one undoable step, and asks for a page. let o = s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap(); - assert_eq!(o.alerts, ["Done"]); + assert_eq!(o.alerts, [crate::js::Message::plain("Done")]); assert_eq!(o.requests, [js::Request::Print]); assert_eq!(value(&s, "total"), ["12"], "recalculated after the change"); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); @@ -4890,17 +4892,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() { assert!(s.take_js_output(id).alerts.is_empty()); let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Console message"]); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]); let out = s.run_javascript(id, "", Some("hello")).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Hello 2"]); + assert_eq!(out.alerts, [js::Message::plain("Hello 2")]); assert_eq!(out.requests, [js::Request::Print]); // The same context rule applies to format/validate/calculate through JsRunner. s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap(); s.take_js_output(id); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); - assert_eq!(s.take_js_output(id).alerts, ["Field message"]); + assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]); // Saved XFA keeps its container scripts for inspection, but does not execute them. let saved = s.get(id).unwrap().bytes.clone(); let reopened = s.open("saved.pdf", None, saved, None).unwrap(); @@ -4925,7 +4927,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts( s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap(); let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Console message"]); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); } #[test] @@ -4942,7 +4944,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); assert!(out.error.is_none(), "{out:?}"); assert_eq!(out.result.as_deref(), Some("6")); - assert_eq!(out.alerts, ["PDF message"]); + assert_eq!(out.alerts, [js::Message::plain("PDF message")]); } } @@ -6250,6 +6252,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() { } } +/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows, +/// with undo and save/reopen. #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -6274,7 +6278,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // A failing validate script shows its message; the value stays, as in Acrobat. s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "total"), vec!["2500".to_string()]); // A button adds a row: new fields, same page, one undo step. let before = names(&s).len(); @@ -6289,7 +6293,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); // Message boxes reach the viewer. let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); // The check box shows the hidden subform; undo hides it again. s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); @@ -6304,7 +6308,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // FormCalc scripts run too. let o = s.run_javascript(id, "", Some("legacy")).unwrap(); assert_eq!(o.error, None); - assert_eq!(o.alerts, vec!["FormCalc".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]); // Saved and reopened: the visible subform, the values and the rows are what they were. let saved = s.save_bytes(id).unwrap(); let id2 = s.open("again.pdf", None, saved, None).unwrap(); @@ -6525,6 +6529,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() { assert_eq!(value(&s, id, "total"), vec!["15".to_string()]); } +/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does. #[test] fn dynamic_xfa_forms_run_formcalc_scripts() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); @@ -6543,7 +6548,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "qty"), vec!["500".to_string()]); // Buttons: a row is added, summed and removed; the message box reaches the viewer. let before = names(&s).len(); @@ -6554,7 +6559,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap(); assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); s.run_javascript(id, "", Some("removeRow")).unwrap(); @@ -6689,6 +6694,8 @@ fn datasets_streams(bytes: &Arc>) -> usize { .count() } +/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links, +/// and noted. #[test] fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { // On open: a calculate setting its own value 50 000 times, an initialize script showing a @@ -6720,6 +6727,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console); } +/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from +/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every +/// one of them and in order. +#[test] +fn xfa_message_boxes_keep_their_title_icon_and_buttons() { + use crate::js::{Message, MessageButtons, MessageIcon}; + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#, + ) + .replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("boxes.pdf", None, bytes, None).expect("opens"); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.error, None); + let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons }; + assert_eq!( + o.alerts, + vec![ + titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo), + titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok), + ] + ); + let o = s.run_javascript(id, "", Some("legacy")).unwrap(); + assert_eq!(o.error, None); + assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]); +} + #[test] fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() { // Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per @@ -8737,69 +8774,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index dd011d47fc..f20c82f6c6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> { } } + /// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted. fn console(&mut self, lines: impl IntoIterator) { for l in lines { if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { @@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> { } } - fn alert(&mut self, m: String) { + /// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted. + fn alert(&mut self, m: crate::js::Message) { if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT { self.run.out.alerts.push(m); } else { @@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names for ev in r.events_of(&som, "validate") { let Some(o) = r.run(&ev, &value)? else { continue }; if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() { - r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))); + r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")))); } } } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 5c80177021..3e68ce081d 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -12,11 +12,11 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, reset_names, resolve_som, run_within, }; +use crate::{Limits, Message, MessageButtons, MessageIcon}; mod access; mod ast; diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index a3218a78f6..0b2dfe4b94 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -1,8 +1,8 @@ use super::ast::{Expr, Stmt}; use super::value::{Val, is_numeric_text, num_text}; use super::{ - CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip, - descendants_named, instances, reset_names, resolve_som, + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, Message, MessageButtons, MessageIcon, R, Rc, + TOO_DEEP, XfaEffect, child_named, clip, descendants_named, instances, reset_names, resolve_som, }; impl Interp<'_> { @@ -237,13 +237,21 @@ impl Interp<'_> { Ok(out) } + /// Call `method` on `base` (`$host`, a form object, an instance manager…) with `args`. fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { let m = method.to_ascii_lowercase(); match base { Val::Host => match m.as_str() { "messagebox" => { - let t = self.arg_str(args, 0)?; - self.h.push(XfaEffect::MessageBox(t)); + // `$host.messageBox(message, title, icon, buttons)`; a missing number is 0, + // the default icon and buttons. + let text = self.arg_str(args, 0)?; + let title = Some(self.arg_str(args, 1)?).filter(|t| !t.is_empty()); + let icon = MessageIcon::from_code(Some(self.arg_num(args, 2)?)); + let buttons = MessageButtons::from_code(Some(self.arg_num(args, 3)?)); + self.h.push(XfaEffect::MessageBox(Message { text, title, icon, buttons })); + // Always OK for now: the viewer can't ask the user and run the script on + // with the answer yet (#4291). Ok(Val::Num(1.0)) } "resetdata" => { diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index a9ad4d2b8f..3b9ebe6028 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -2,27 +2,85 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie use super::format::{printd, printf, printx, scand}; use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; -use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; +use super::{ + Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, + js_string, +}; // ── app, util, console ────────────────────────────────────────────────────────────────────── /// A message argument: a string, or an object with `cMsg`, cut to a message's length. fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { - match v.as_object() { - Some(o) if !o.is_array() && !o.is_callable() => { + match named_params(v, key, ctx)? { + Some(o) => { let m = o.get(JsString::from(key), ctx)?; text_line(&m, ctx) } - _ => text_line(v, ctx), + None => text_line(v, ctx), } } -/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and -/// returns 1 (the OK button). +/// `v` as a method's single object of named parameters: an object (not an array or function) +/// that has `key`, the parameter every such call names. Any other value, a `new String(…)` or a +/// caught `Error` included, is the first positional argument, read as text. +fn named_params(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult> { + match v.as_object() { + Some(o) if !o.is_array() && !o.is_callable() && o.has_property(JsString::from(key), ctx)? => Ok(Some(o.clone())), + _ => Ok(None), + } +} + +/// A message's title: `v` as text cut to a message's length, `None` when it is missing or empty. +fn title(v: &JsValue, ctx: &mut Context) -> JsResult> { + Ok(Some(text_line(v, ctx)?).filter(|t| !t.is_empty())) +} + +/// An icon or buttons number: `v` as a number, `None` when it is missing or isn't one (a value +/// that can't be converted counts as missing, so a bad argument never stops the message). +fn code(v: &JsValue, ctx: &mut Context) -> Option { + if v.is_undefined() || v.is_null() { + return None; + } + v.to_number(ctx).ok() +} + +/// `app.alert`'s message: `(cMsg, nIcon, nType, cTitle)`, or one object with those keys (as +/// the JavaScript for Acrobat API Reference allows for every method with named parameters). +pub(crate) fn alert_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + let first = arg(args, 0); + let (text, icon, buttons, t) = match named_params(&first, "cMsg", ctx)? { + Some(o) => { + let get = |k: &str, ctx: &mut Context| o.get(JsString::from(k), ctx); + (get("cMsg", ctx)?, get("nIcon", ctx)?, get("nType", ctx)?, get("cTitle", ctx)?) + } + None => (first, arg(args, 1), arg(args, 2), arg(args, 3)), + }; + Ok(Message { + text: text_line(&text, ctx)?, + title: title(&t, ctx)?, + icon: MessageIcon::from_code(code(&icon, ctx)), + buttons: MessageButtons::from_code(code(&buttons, ctx)), + }) +} + +/// `xfa.host.messageBox`'s message: `(message, title, icon, buttons)`, as the XFA +/// specification orders them. +pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(Message { + text: text_line(&arg(args, 0), ctx)?, + title: title(&arg(args, 1), ctx)?, + icon: MessageIcon::from_code(code(&arg(args, 2), ctx)), + buttons: MessageButtons::from_code(code(&arg(args, 3), ctx)), + }) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)`: records the message with its title, icon and buttons, +/// given in that order or as one object with those keys, and returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = message(&arg(args, 0), "cMsg", ctx)?; + let m = alert_message(args, ctx)?; host(ctx)?.borrow_mut().alert(m); - // The OK button. + // Always the OK button for now: the viewer can't ask the user and run the script on with + // the answer yet (#4291). Ok(JsValue::from(1)) } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 6e442d2a2b..8b3b1e6f1c 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -1,14 +1,14 @@ use super::model::{DocInfo, FieldState, FieldType, Request}; use super::run::MAX_ARRAY_LENGTH; use super::{ - Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, - xfa, + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction, + RefCell, xfa, }; pub(crate) struct Host { pub(crate) fields: Arc>, pub(crate) changed: BTreeSet, - pub(crate) alerts: Vec, + pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, pub(crate) doc: DocInfo, @@ -33,10 +33,12 @@ impl Host { } } - pub(crate) fn alert(&mut self, m: String) { + /// Keep message `m`, its text and title charged to the run's byte budget, up to + /// [`xfa::MAX_ALERTS`] messages. + pub(crate) fn alert(&mut self, m: Message) { if self.alerts.len() >= xfa::MAX_ALERTS { self.dropped = self.dropped.saturating_add(1); - } else if self.room(m.len()) { + } else if self.room(m.bytes()) { self.alerts.push(m); } } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 896ad3466d..93f57e7528 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -48,7 +48,8 @@ pub(crate) use globals::menu_item; use globals::platform; use host::{arg, as_number, error, function, s, text}; pub use model::{ - DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Outcome, Panel, Request, + DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Message, MessageButtons, + MessageIcon, Outcome, Panel, Request, }; #[cfg(not(target_arch = "wasm32"))] use run::SCRIPT_STACK; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 80583d0de0..36b09bec97 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -115,6 +115,111 @@ pub struct DocInfo { pub xfa: bool, } +/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s +/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA +/// specification alike. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageIcon { + /// 0, the default. + #[default] + Error, + /// 1. + Warning, + /// 2. + Question, + /// 3. + Status, +} + +impl MessageIcon { + /// The icon numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageIcon { + match whole_code(code) { + Some(1) => MessageIcon::Warning, + Some(2) => MessageIcon::Question, + Some(3) => MessageIcon::Status, + _ => MessageIcon::Error, + } + } + + /// A stable name for tools and logs: `error`, `warning`, `question` or `status`. + pub fn name(self) -> &'static str { + match self { + MessageIcon::Error => "error", + MessageIcon::Warning => "warning", + MessageIcon::Question => "question", + MessageIcon::Status => "status", + } + } +} + +/// The buttons a script's message offers: `app.alert`'s `nType` and `xfa.host.messageBox`'s +/// buttons, numbered 0 to 3 in both references. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageButtons { + /// 0, the default. + #[default] + Ok, + /// 1. + OkCancel, + /// 2. + YesNo, + /// 3. + YesNoCancel, +} + +impl MessageButtons { + /// The buttons numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageButtons { + match whole_code(code) { + Some(1) => MessageButtons::OkCancel, + Some(2) => MessageButtons::YesNo, + Some(3) => MessageButtons::YesNoCancel, + _ => MessageButtons::Ok, + } + } + + /// A stable name for tools and logs: `ok`, `ok_cancel`, `yes_no` or `yes_no_cancel`. + pub fn name(self) -> &'static str { + match self { + MessageButtons::Ok => "ok", + MessageButtons::OkCancel => "ok_cancel", + MessageButtons::YesNo => "yes_no", + MessageButtons::YesNoCancel => "yes_no_cancel", + } + } +} + +/// `code` as one of the numbers 0 to 3 the references define, when it is exactly one. +fn whole_code(code: Option) -> Option { + let c = code.filter(|c| c.is_finite() && c.fract() == 0.0 && (0.0..=3.0).contains(c))?; + // In 0..=3 and whole, so the cast is exact. + Some(c as u8) +} + +/// A message a script showed (`app.alert`, `xfa.host.messageBox`), with the title, icon and +/// buttons it asked for. Its text and title are cut to a message's length. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Message { + pub text: String, + /// The dialog's title, when the script gave one. + pub title: Option, + pub icon: MessageIcon, + pub buttons: MessageButtons, +} + +impl Message { + /// A message with only text: no title, the default icon and an OK button. + pub fn plain(text: impl Into) -> Message { + Message { text: text.into(), ..Default::default() } + } + + /// Bytes of text the message holds, charged to a run's budget. + pub(crate) fn bytes(&self) -> usize { + self.text.len().saturating_add(self.title.as_ref().map_or(0, String::len)) + } +} + /// Something a script asked the viewer to do. #[derive(Clone, Debug, PartialEq)] pub enum Request { @@ -173,7 +278,8 @@ pub struct Outcome { pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, - pub alerts: Vec, + /// The messages the script showed, in order. + pub alerts: Vec, pub console: Vec, pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 38cebf54ce..2d3877efe3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() { assert_eq!(o.value, "string:number"); } +/// A validate script refuses a value with `event.rc = false` and says why in a message. #[test] fn validate_scripts_reject_with_rc_and_alert() { let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11")); assert!(!o.rc); - assert_eq!(o.alerts, ["Too many"]); + assert_eq!(o.alerts, [Message::plain("Too many")]); let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3")); assert!(o.rc); } @@ -251,9 +252,11 @@ fn kept_bytes(o: &Outcome) -> usize { Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), _ => 0, }; - o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() + o.console.iter().map(String::len).sum::() + o.alerts.iter().map(Message::bytes).sum::() + o.requests.iter().map(req).sum::() } +/// Console lines, messages and requests are capped in number and in bytes, and what is left out is +/// noted. #[test] fn exec_menu_item_requests_are_capped_like_other_requests() { let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", "")); @@ -275,11 +278,99 @@ fn console_alerts_and_requests_are_capped_in_count_and_bytes() { assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); - assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097)); assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); } +/// The message `app.alert(args)` shows, from a button's Mouse Up script. +fn alerted(args: &str) -> Message { + let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None, "{args}"); + assert_eq!(o.alerts.len(), 1, "{args}"); + o.alerts.into_iter().next().unwrap_or_default() +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for +/// Acrobat API Reference numbers, and the title. +#[test] +fn app_alert_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + // Only the message: no title, the Error icon and the OK button, the defaults. + assert_eq!(alerted("'Saved'"), Message::plain("Saved")); + assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") }); +} + +/// The single-object form names the same parameters, in any order, and reads nothing else. +#[test] +fn app_alert_reads_the_object_form() { + let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }"); + assert_eq!( + m, + Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo } + ); + assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done")); +} + +/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a +/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`. +#[test] +fn objects_that_name_no_parameter_are_the_message_itself() { + assert_eq!(alerted("new String('Saved')"), Message::plain("Saved")); + let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}"); + let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}"); +} + +/// An icon or button number that isn't one of the four (fractional, negative, too large, not a +/// number, or a value that can't be converted) is the default, and the message still shows; +/// a number given as text counts. +#[test] +fn app_alert_falls_back_on_bad_icon_and_button_numbers() { + for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] { + let m = alerted(&format!("'Hi', {bad}, {bad}, ''")); + assert_eq!(m, Message::plain("Hi"), "{bad}"); + } + assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") }); + // Every call still answers OK (1) until answers are asked for (#4291). + let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.value, "1"); +} + +/// A title is cut to a message's length like the text, and both are charged to the run's byte +/// budget: titles can't carry more text out than messages can. +#[test] +fn app_alert_titles_are_clipped_and_charged() { + use crate::host::Host; + let m = alerted("'Hi', 0, 0, new Array(100000).join('t')"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len)); + // The title's bytes count against the budget with the text's, and a message the budget + // has no room for is left out and counted. + let mut h = Host { + fields: Arc::new(Vec::new()), + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc(), + kept: 0, + dropped: 0, + }; + h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") }); + assert_eq!(h.kept, "Saved".len() + "Order form".len()); + h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len(); + h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") }); + assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out"); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); @@ -435,8 +526,8 @@ fn scripts_cannot_compile_code_from_strings() { } mod xfa_model { - use crate::Limits; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { XfaNode { @@ -670,6 +761,8 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("4/1")); } + /// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep + /// their details. #[test] fn host_layout_event_and_app_calls_become_effects() { let o = go( @@ -681,7 +774,12 @@ mod xfa_model { assert_eq!( o.effects, vec![ - XfaEffect::MessageBox("Hi".into()), + XfaEffect::MessageBox(Message { + text: "Hi".into(), + title: Some("T".into()), + icon: MessageIcon::Status, + buttons: MessageButtons::OkCancel + }), XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::SaveAs, @@ -726,6 +824,7 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("1002")); } + /// A value set again replaces the earlier one, and hostile loops end at the caps. #[test] fn values_set_again_merge_and_hostile_loops_are_capped() { // A calculate that sets its own value in a loop leaves one effect: the last value. @@ -747,7 +846,56 @@ mod xfa_model { assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes); // Long messages are cut. let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); - assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); + assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097)); + } + + /// The message box `script` showed from a button's click script. + fn boxed(script: &str) -> Message { + let o = go(script, "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None, "{script}"); + match o.effects.as_slice() { + [XfaEffect::MessageBox(m)] => m.clone(), + other => panic!("{script}: {other:?}"), + } + } + + /// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the + /// XFA specification numbers, and the title. + #[test] + fn message_box_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved")); + // A bad number is the default; the message still shows. + assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi")); + assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi")); + let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)); + // Answers are always OK for now (#4291). + let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click"); + assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects); + } + + /// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`) + /// and its object form, not `messageBox`'s. + #[test] + fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() { + let m = boxed("app.alert('Sure?', 1, 3, 'Order form')"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel } + ); + let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel } + ); } /// Past the byte budget, adding or removing a row is left out of the snapshot as well as the @@ -818,6 +966,7 @@ mod xfa_model { assert_eq!(names, 1); } + /// Time the engine takes to start is not taken from the script's own time. #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -852,9 +1001,9 @@ mod xfa_model { mod formcalc { use super::xfa_model::form; - use crate::Limits; use crate::formcalc::run_formcalc; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; @@ -1165,6 +1314,7 @@ s"# assert!(o.error.unwrap().contains("endif")); } + /// FormCalc resolves form objects, assigns to them and records the effects. #[test] fn form_objects_resolve_assign_and_record_effects() { assert_formcalc_reads_fields_and_rows(); @@ -1242,7 +1392,7 @@ table._row.count"#, XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() }, XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() }, - XfaEffect::MessageBox("Total 17.5".into()), + XfaEffect::MessageBox(Message::plain("Total 17.5")), XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] @@ -1311,6 +1461,7 @@ $host.gotoURL("https://example.org")"#, assert_eq!(run(&script).result.as_deref(), Some("2")); } + /// Hostile FormCalc scripts end with an error and bounded output. #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { assert_hostile_control_flow_is_bounded(); @@ -1361,7 +1512,7 @@ $host.gotoURL("https://example.org")"#, ); assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); - assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len()); let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); @@ -1467,6 +1618,27 @@ $host.gotoURL("https://example.org")"#, assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + /// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button + /// set and the title, as the JavaScript one does. + #[test] + fn formcalc_message_box_keeps_its_title_icon_and_buttons() { + let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click"); + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})")); + let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }; + assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}"); + } + } + // Missing or bad numbers are the defaults; a long title is cut like the message. + assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + let o = fc("$host.messageBox(\"Hi\", Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097))); + } + fn run_hostile_formcalc(script: &str) -> XfaOutcome { let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; let doc = XfaDoc::default(); @@ -1884,5 +2056,5 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() { let o = go(XFA_CHECK, &Event::doc("Open")); assert_eq!(o.error, None); assert_eq!(o.value, "undefined number 3.5"); - assert_eq!(o.alerts, ["old viewer"]); + assert_eq!(o.alerts, [Message::plain("old viewer")]); } diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index cddf8f2eb2..eb275d1822 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; +use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; mod globals; mod host; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 54508e6cab..971cf12fc1 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -9,17 +9,28 @@ use super::{ // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── -/// Records effect `e` on the host, as [`XHost::push`] does. +/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does. fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { host(ctx)?.borrow_mut().push(e); Ok(()) } -/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and -/// returns 1. +/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title, +/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); + let m = crate::globals::message_box_message(args, ctx)?; effect(ctx, XfaEffect::MessageBox(m))?; + // Always OK for now: the viewer can't ask the user and run the script on with the answer + // yet (#4291). + Ok(JsValue::from(1.0)) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` in an XFA form's script: the same message box, its +/// arguments in `app.alert`'s order (or one object with those keys). +fn app_alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = crate::globals::alert_message(args, ctx)?; + effect(ctx, XfaEffect::MessageBox(m))?; + // The OK button, as for `messageBox` (#4291). Ok(JsValue::from(1.0)) } @@ -211,7 +222,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current ctx.register_global_property(js_string!("event"), ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4) .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index ea2d4d4332..ec222a4ca8 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -2,7 +2,7 @@ use super::model::{ MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, effect_key, payload, }; -use super::{Context, HashMap, JsResult, RefCell, error}; +use super::{Context, HashMap, JsResult, Message, RefCell, error}; /// One node of the flattened form, shared by the JavaScript and FormCalc engines. pub(crate) struct HNode { @@ -133,7 +133,7 @@ impl XHost { /// `e` with its text cut to the lengths an effect keeps. fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { match e { - XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(Message { text: clip(m.text), title: m.title.map(clip), ..m }), XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 83b2d7d149..04ec0f8349 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -1,4 +1,4 @@ -use super::HashSet; +use super::{HashSet, Message}; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -95,7 +95,8 @@ pub enum XfaEffect { RemoveInstance { som: String, }, - MessageBox(String), + /// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script. + MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. @@ -209,7 +210,8 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), - XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::MessageBox(m) => m.bytes(), + XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8925a0dca1..5de2485857 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -105,6 +105,10 @@ impl PdfCraftApp { open_prepare_gate: None, pinned: Default::default(), toast: None, + toast_script: None, + script_messages: std::collections::VecDeque::new(), + script_messages_dropped: 0, + toast_dropped: 0, integrated_titlebar: false, window_controls: None, system_title_bar: false, @@ -284,8 +288,13 @@ impl PdfCraftApp { || self.updates.open } + /// Show `msg` in the toast now, in place of whatever it showed. Script messages still waiting + /// show after it. pub fn notify(&mut self, msg: impl Into) { + // A left-out count it replaces is said again once the toast is free. + self.script_messages_dropped = self.script_messages_dropped.saturating_add(std::mem::take(&mut self.toast_dropped)); self.toast = Some((msg.into(), 0.0)); + self.toast_script = None; } /// Notify with a static message in the UI language. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65bc7e4b1b..a151f8ecd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -359,6 +359,17 @@ pub struct PdfCraftApp { /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, + /// The form script message the toast shows now, if it shows one: drawn with its title and + /// icon (#4293). + pub toast_script: Option, + /// Form script messages waiting for the toast, in the order the scripts showed them: each + /// takes the toast when the one before it is gone, so every one is seen (#4294). + pub script_messages: std::collections::VecDeque, + /// Form script messages the waiting queue had no room for, since the toast last said how many. + pub script_messages_dropped: u64, + /// The left-out count the toast is saying now ([`Self::next_script_message`]): put back to be + /// said again if another notice replaces the toast before it has been shown its time. + pub toast_dropped: u64, /// Whether the macOS title bar is drawn by us (traffic lights over our tab strip). pub integrated_titlebar: bool, /// Set when the desktop leaves the window frame to the app (GNOME on Wayland): the tab strip diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cb34b5901b..1c688b9bb3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -433,14 +433,28 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru /// Transient message at the bottom centre. pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { + // A form script message waiting its turn takes the toast once it is free. + if app.toast.is_none() { + app.toast_script = None; + // A left-out count whose toast was cleared before its time is said again. + app.script_messages_dropped = app.script_messages_dropped.saturating_add(std::mem::take(&mut app.toast_dropped)); + app.next_script_message(); + } let Some((msg, start)) = app.toast.clone() else { return }; let now = ctx.input(|i| i.time); let start = if start == 0.0 { now } else { start }; app.toast = Some((msg.clone(), start)); if now - start > 3.5 { app.toast = None; + app.toast_script = None; + // Shown its time: a left-out count it said is done. + app.toast_dropped = 0; + if app.next_script_message() { + ctx.request_repaint(); + } return; } + let script = app.toast_script.as_ref().map(|m| (m.title.clone(), crate::js_ui::message_icon(m.icon))); let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Above the progress notice while a job runs. @@ -461,10 +475,26 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { // Allow natural short labels; wrap longer ones within the viewport, // including the frame's 32 points and 16-point outside margins. ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0)); - ui.add( - egui::Label::new(egui::RichText::new(msg).color(if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE })) - .wrap(), - ); + let ink = if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }; + let text = |ui: &mut egui::Ui| { + ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap()); + }; + match script { + // A form script's message: its icon, then its title (when it gave one) + // over its text. + Some((title, icon)) => { + ui.horizontal_top(|ui| { + ui.add(icons::image(icon, 16.0, ink)); + ui.vertical(|ui| { + if let Some(title) = title { + ui.add(egui::Label::new(egui::RichText::new(title).strong().color(ink)).wrap()); + } + text(ui); + }); + }); + } + None => text(ui), + } }); }); ctx.request_repaint_after(std::time::Duration::from_millis(100)); diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 4296487e4c..b8f5d51cc6 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -2,7 +2,7 @@ //! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript. use egui::{Align, Layout}; -use pdfcraft_engine::js::{JsOutput, Panel, Request}; +use pdfcraft_engine::js::{JsOutput, Message, MessageIcon, Panel, Request}; use pdfcraft_engine::{DocId, Edit}; use crate::theme::{self, Tokens}; @@ -22,6 +22,21 @@ pub struct DocJsDraft { pub script: String, } +/// Most form script messages that wait for the toast; past it, more are left out and counted, and +/// the toast says how many once the rest have been shown. One run shows at most this many (the +/// script engine's own limit), so only runs in quick succession reach it. +const MAX_WAITING_MESSAGES: usize = 100; + +/// The icon a script message's toast shows for `icon`. +pub(crate) fn message_icon(icon: MessageIcon) -> &'static str { + match icon { + MessageIcon::Error => "circle-x", + MessageIcon::Warning => "triangle-alert", + MessageIcon::Question => "circle-help", + MessageIcon::Status => "info", + } +} + /// What the user is told when a form's scripts were turned off: in a toast as it happens, then /// on the notice bar for as long as the document is open. pub(crate) fn scripts_off_notice() -> String { @@ -81,11 +96,47 @@ impl PdfCraftApp { Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } - if let Some(a) = out.alerts.last() { - self.notify(a.clone()); + self.show_script_messages(out.alerts); + } + + /// Show form script messages `messages` in order, each in the toast with its title and icon. + /// The first takes the toast at once when it is free; otherwise it waits, like the rest, for + /// what the toast shows (another message, or a notice such as the run's own submit request) + /// to go ([`Self::next_script_message`]). + pub fn show_script_messages(&mut self, messages: Vec) { + let room = MAX_WAITING_MESSAGES.saturating_sub(self.script_messages.len()); + let left_out = u64::try_from(messages.len().saturating_sub(room)).unwrap_or(u64::MAX); + self.script_messages_dropped = self.script_messages_dropped.saturating_add(left_out); + self.script_messages.extend(messages.into_iter().take(room)); + if self.toast.is_none() { + self.next_script_message(); } } + /// Put the next waiting script message in the toast, or, once none waits, a notice of how many + /// the queue had no room for. False when there is neither. + pub fn next_script_message(&mut self) -> bool { + let Some(m) = self.script_messages.pop_front() else { + // Every message the queue held has been shown: say how many it had no room for. + if self.script_messages_dropped == 0 { + return false; + } + let n = std::mem::take(&mut self.script_messages_dropped); + self.notify(crate::i18n::trn( + crate::i18n::current(), + n, + "{n} more message from the form's scripts was left out.", + "{n} more messages from the form's scripts were left out.", + )); + // Kept with the toast until it has been shown its time (the toast's own expiry). + self.toast_dropped = n; + return true; + }; + self.toast = Some((m.text.clone(), 0.0)); + self.toast_script = Some(m); + true + } + /// Run a push button's JavaScript (its Mouse Up action). pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) { // A script reads the fields: include what's still being typed in one (#166). diff --git a/crates/ui-egui/tests/it/javascript.rs b/crates/ui-egui/tests/it/javascript.rs index 2d525104bf..b4aaa4224f 100644 --- a/crates/ui-egui/tests/it/javascript.rs +++ b/crates/ui-egui/tests/it/javascript.rs @@ -70,6 +70,7 @@ fn console_runs_scripts_against_the_document() { assert!(h.state().js_console.log.is_empty(), "{:?} {:?}", h.state().js_console.log, h.state().dialog); } +/// A button's script fills a field and its message takes the toast. #[test] fn button_scripts_run_and_alert() { let mut h = harness(); @@ -80,6 +81,80 @@ fn button_scripts_run_and_alert() { assert_eq!(h.state().toast.clone().unwrap().0, "Filled in"); } +/// One run's messages are all shown, in order (#4294): the first takes the toast with its title +/// and icon (#4293), the next takes it when the first is gone, and a notice shown meanwhile +/// doesn't lose the ones still waiting. Messages past the queue's room are counted and reported +/// once the rest have been shown. +#[test] +fn every_message_a_script_shows_takes_the_toast_in_turn() { + use pdfcraft_engine::js::{MessageButtons, MessageIcon}; + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + h.state_mut().run_button_script(id, "hello", "app.alert('First', 1, 0, 'Order form'); app.alert('Second', 3); app.alert('Third');"); + h.run_steps(2); + let shown = |h: &Harness<'static, PdfCraftApp>| h.state().toast.clone().map(|t| t.0); + assert_eq!(shown(&h).as_deref(), Some("First")); + let m = h.state().toast_script.clone().expect("the toast shows the script's message"); + assert_eq!((m.title.as_deref(), m.icon, m.buttons), (Some("Order form"), MessageIcon::Warning, MessageButtons::Ok)); + assert!(h.query_by_label("Order form").is_some(), "the title is drawn"); + assert_eq!(h.state().script_messages.len(), 2); + // The first is gone: the second takes its place, with its own icon and no title. + h.state_mut().toast = None; + h.run_steps(2); + assert_eq!(shown(&h).as_deref(), Some("Second")); + assert_eq!(h.state().toast_script.as_ref().map(|m| (m.title.clone(), m.icon)), Some((None, MessageIcon::Status))); + // A notice of the app's own replaces it; the third still follows. + h.state_mut().notify("Saved"); + assert_eq!(h.state().toast_script, None); + h.state_mut().toast = None; + h.run_steps(2); + assert_eq!(shown(&h).as_deref(), Some("Third")); + assert!(h.state().script_messages.is_empty()); + // A notice the same run raises first (its submit request) isn't replaced: the message + // follows it. + h.state_mut().toast = None; + h.run_steps(2); + h.state_mut().run_button_script(id, "hello", "this.submitForm('https://example.com/form'); app.alert('Fourth');"); + h.run_steps(2); + let notice = shown(&h).unwrap_or_default(); + assert!(notice.contains("submitted"), "the submit notice stays: {notice}"); + assert_eq!(h.state().script_messages.len(), 1); + h.state_mut().toast = None; + h.run_steps(2); + assert_eq!(shown(&h).as_deref(), Some("Fourth")); + // Past the 100 the queue holds, messages are counted, and the toast says how many once the + // rest have been shown. + h.state_mut().toast = None; + h.run_steps(2); + let many: Vec<_> = (0..105).map(|i| pdfcraft_engine::js::Message::plain(format!("Message {i}"))).collect(); + h.state_mut().show_script_messages(many); + assert_eq!(shown(&h).as_deref(), Some("Message 0")); + for i in 1..100 { + assert!(h.state_mut().next_script_message()); + assert_eq!(shown(&h), Some(format!("Message {i}"))); + } + assert!(h.state_mut().next_script_message(), "the notice comes after the last message"); + assert_eq!(shown(&h).as_deref(), Some("5 more messages from the form's scripts were left out.")); + assert_eq!(h.state().toast_script, None, "an app notice, not a script message"); + assert!(!h.state_mut().next_script_message(), "said once"); + // Another notice replacing it before its time doesn't lose the count: once that notice has + // been shown its time, the count is said again, and then, its own time over, not again. + h.state_mut().notify("Saved"); + assert_eq!(shown(&h).as_deref(), Some("Saved")); + let left_out = "5 more messages from the form's scripts were left out."; + let mut said_again = false; + for _ in 0..600 { + h.run_steps(1); + said_again |= shown(&h).as_deref() == Some(left_out); + if said_again && shown(&h).is_none() { + break; + } + } + assert!(said_again, "the count is said again after the notice that replaced it"); + assert_eq!(shown(&h), None, "and once only"); + assert!(!h.state_mut().next_script_message()); +} + #[test] fn document_scripts_and_preferences() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2a17a9f02e..d9fbbd1291 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5212,8 +5212,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" @@ -5308,8 +5308,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill", "js_run"] -evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"] -notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures" +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] +notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)." [[feature]] id = "form.xfa-data" From 8f3a4f18bdffe6fcf2fce5d6f20bb0d9a6e91842 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 28 Oct 2026 14:48:29 -0700 Subject: [PATCH 65/75] =?UTF-8?q?M6:=20Organize=20pages=20=E2=96=B8=20Page?= =?UTF-8?q?=20properties=20sets=20the=20selected=20pages'=20tab=20order=20?= =?UTF-8?q?(#4516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M6: Organize pages ▸ Page properties sets the selected pages' tab order Page properties in the Organize pages panel was listed as planned. In the app, a page's tab order could only be set for every page at once, with the form.tab_order commands, which offer no Unspecified. (The form_tab_order tool already set chosen pages, Unspecified included.) - Page properties (page.properties) opens a Tab Order dialog for the selected pages, with Use Row Order, Use Column Order, Use Document Structure and Unspecified. OK writes Edit::SetTabOrder for those pages as one undo step. The dialog opens on the pages' order, or with no choice made when they differ, and then OK is off until one is chosen. OK on the order the pages already have adds no undo step. - Rows and columns run right to left in a document bound on the right, as Use Row Order and Use Column Order are described. PdfCraft reads a right-to-left binding from /ViewerPreferences /Direction /R2L (ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to right. - A page whose /Tabs is an indirect object now takes its row, column or structure order. Before, rank_tabs read only a direct name and ranked the page in annotation order. - pdfcraft_forms::tab_orders and Document::tab_orders read each page's /Tabs. form_tab_order returns them as page_tabs, and reads without changing anything when it is given no order or field. - set_option("dialog", "page-properties") opens the dialog. - Parity: organize.page-tab-order is shipped, and its notes say the Actions tab of Page Properties (organize.page-actions) is still planned; the Organize pages grid notes list Page properties. * Page properties: OK applies only to the document the dialog was opened on The draft keeps the document's id. When another document is active by OK, the dialog closes without changing either document. * Menus: list Page properties… in the Pages menu The menu bar now lists its commands explicitly, and every command with a menu group must appear in it. * Page properties: apply the tab order only to an unchanged document An edit, undo or redo made while the dialog is open can move the pages at the stored indices. OK now closes the dialog without changing a page when the document's edit generation differs from when it opened. * Page properties: say when a document change cancels the tab order OK shows a notice when the document changed or another document became active while the dialog was open, so the user knows to open it again. --- crates/automation/src/forms.rs | 36 ++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 17 +++ crates/engine/src/commands.rs | 1 + crates/engine/src/document.rs | 5 + crates/forms/src/lib.rs | 2 +- crates/forms/src/tab_order.rs | 33 +++++- crates/forms/src/tests.rs | 67 +++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 5 + crates/ui-egui/src/commands.rs | 14 +++ crates/ui-egui/src/dialogs.rs | 23 ++++ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/organize_panel.rs | 28 ++++- crates/ui-egui/src/state.rs | 14 +++ crates/ui-egui/tests/it/editing.rs | 119 ++++++++++++++++++++ parity/acrobat-features.toml | 12 +- 18 files changed, 365 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 71fbc67b3b..b4ce82df59 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -600,17 +600,23 @@ impl Automation { }; let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; return Ok(out); } - let order = match a.str("order")? { - "row" | "rows" => pdfcraft_engine::TabOrder::Row, - "column" | "columns" => pdfcraft_engine::TabOrder::Column, - "structure" => pdfcraft_engine::TabOrder::Structure, - "annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations, - o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + let order = match a.opt_str("order")? { + None => None, + Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row), + Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column), + Some("structure") => Some(pdfcraft_engine::TabOrder::Structure), + Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations), + Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + }; + let mut out = match order { + Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?, + None => json!({}), }; - let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; Ok(out) } @@ -671,6 +677,22 @@ impl Automation { Ok(json!(seq.iter().map(|x| x.1).collect::>())) } + /// Each page's tab order (Page properties ▸ Tab order), in page order. + fn page_tabs(&self, a: &Args) -> Result { + let names: Vec<&str> = self + .doc(a)? + .tab_orders() + .into_iter() + .map(|o| match o { + pdfcraft_engine::TabOrder::Row => "row", + pdfcraft_engine::TabOrder::Column => "column", + pdfcraft_engine::TabOrder::Structure => "structure", + pdfcraft_engine::TabOrder::Annotations => "annotations", + }) + .collect(); + Ok(json!(names)) + } + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { let name = a.str("field")?.to_owned(); if !self.doc(a)?.form.iter().any(|f| f.name == name) { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 78a6805bb3..c4994aace1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -464,7 +464,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") + t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index fb904bacfe..382bda1f4b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4940,6 +4940,23 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +#[test] +fn page_tab_orders_are_read_and_set_per_page_through_tools() { + let dir = workdir("pagetabs"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc })); + assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"])); + assert!(r["undo"].is_null(), "reading changes nothing: {r}"); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] })); + assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"])); + assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true))); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] })); + assert_eq!(r["page_tabs"], json!(["column", "structure", "column"])); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] })); + assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"])); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..2412658240 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -392,6 +392,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 7023caa55e..e66b0d0f17 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -248,6 +248,11 @@ impl Document { self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() } + /// Page properties ▸ Tab order: each page's tab order, in page order. + pub fn tab_orders(&self) -> Vec { + self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 6d8cf9c99d..863376af3f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -46,7 +46,7 @@ pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopte #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; use read::{script, script_budget}; -pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; use values::write_value; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 77234ee094..5b60e3927b 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -4,9 +4,11 @@ use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). + /// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound + /// on the right). Row, - /// Columns, left to right, top to bottom within a column (`/C`). + /// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a + /// document bound on the right). Column, /// The document structure (`/S`; annotation order when the file has none). Structure, @@ -23,6 +25,12 @@ impl TabOrder { _ => TabOrder::Annotations, } } + + /// The tab order of the page at `r`, with an indirect `/Tabs` resolved. + fn of_page(doc: &Document, r: ObjRef) -> TabOrder { + let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o)); + TabOrder::of(tabs.as_deref().and_then(Object::as_name)) + } } /// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). @@ -38,13 +46,20 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } } + // Rows and columns run right to left in a document bound on the right: rank the mirrored + // rectangles there. + if right_to_left(doc) { + for i in &mut items { + i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]]; + } + } let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); for (pi, p) in pages.iter().enumerate() { let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); if on.is_empty() { continue; } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); + let tabs = TabOrder::of_page(doc, *p); match tabs { TabOrder::Row | TabOrder::Column => { let row = tabs == TabOrder::Row; @@ -87,6 +102,13 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col } } +/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`). +fn right_to_left(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false }; + prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L")) +} + /// Set the tab order of pages (0-based). pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { let refs = page_refs(doc); @@ -104,6 +126,11 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re Ok(()) } +/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified). +pub fn tab_orders(doc: &Document) -> Vec { + page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect() +} + /// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): /// move field `name` one place earlier or later in its page's tab order. The page switches to /// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d8199f15d3..e8ab3b57ea 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1875,6 +1875,73 @@ fn tab_order_follows_the_page_setting() { assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); } +#[test] +fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + let root = doc.root().unwrap(); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"Direction".to_vec(), Object::name("R2L")); + let prefs = doc.add(prefs); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom"); + assert_eq!(order(&reopen(&doc)), "BDAC"); + set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap(); + assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order"); + doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD", "bound on the left"); +} + +#[test] +fn tab_orders_are_read_for_each_page() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(), + "/R".into(), + ]); + use TabOrder::{Annotations, Column, Row, Structure}; + assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified"); + set_tab_order(&mut doc, &[1], Row).unwrap(); + set_tab_order(&mut doc, &[0, 4], Annotations).unwrap(); + let doc = reopen(&doc); + assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]); +} + +#[test] +fn an_indirect_tabs_value_orders_the_page() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let column = doc.add(Object::name("C")); + let page = page_refs(&doc)[0]; + doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap(); + let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + assert_eq!(all.into_iter().map(|x| x.1).collect::(), "ACBD", "columns, as a direct /C orders them"); + assert_eq!(tab_orders(&doc), [TabOrder::Column]); +} + #[test] fn options_tab_flags_alignment_and_defaults() { let mut doc = one_page(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5de2485857..f846b489b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -194,6 +194,7 @@ impl PdfCraftApp { v_pressed: false, attach_override: None, duplicate_draft: None, + page_props_draft: None, field_table_draft: None, form_preview: false, window_title: String::new(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bb5a65cb34..c94e77ffbb 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -120,6 +120,11 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "page-properties" => { + // Same path as the panel, so the selected pages and their order are seeded. + self.execute("page.properties"); + self.dialog + } "none" => None, _ => Some(Dialog::About), } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index acccbcc55a..0889a447d2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -355,6 +355,20 @@ impl PdfCraftApp { } self.dialog = Some(Dialog::NumberPages); } + "page.properties" => { + if let Some(i) = active + && let Some(doc) = self.session.get(self.views[i].id) + { + let pages = self.views[i].target_pages(); + let orders = doc.tab_orders(); + let mut theirs = pages.iter().map(|p| orders.get(*p).copied()); + let first = theirs.next().flatten(); + let before = if theirs.all(|o| o == first) { first } else { None }; + let generation = doc.edit_generation(); + self.page_props_draft = Some(crate::PagePropsDraft { doc: self.views[i].id, generation, pages, before, tab_order: before }); + self.dialog = Some(Dialog::PageProps); + } + } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c21604a4da..2a1c16003c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -52,6 +52,7 @@ struct Pending { summarize_now: bool, optimize_now: bool, duplicate_now: Option, + page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>, replace_now: bool, open_revision: Option, a11y_now: bool, @@ -218,6 +219,19 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } } + Dialog::PageProps => { + let Some(d) = app.page_props_draft.as_mut() else { + req.close = true; + return false; + }; + let (ok, cancel) = crate::organize_panel::page_props_body(ui, d); + // An order every page already has adds no undo step. + if ok && let Some(order) = d.tab_order.filter(|o| d.before != Some(*o)) { + req.page_props_now = Some((d.doc, d.generation, Edit::SetTabOrder { pages: d.pages.clone(), order })); + } + req.close = ok || cancel; + return false; + } Dialog::DuplicateField => { if !dialog_duplicate_field(ui, app, req) { return false; @@ -1643,6 +1657,15 @@ fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { app.duplicate_draft = None; app.apply_edit(e); } + // Another document became active, or the document changed, while the dialog was open: the + // pages at those indices may not be the ones chosen. + if let Some((doc, generation, e)) = req.page_props_now.take() { + if app.active_ids().is_some_and(|(_, id)| id == doc) && app.session.get(doc).is_some_and(|d| d.edit_generation() == generation) { + app.apply_edit(e); + } else { + app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order."); + } + } if req.extract_now { app.extract_selection(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a151f8ecd6..71dd140752 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -127,8 +127,8 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PasswordPrompt, PendingLink, - PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, + CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, + PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -529,6 +529,8 @@ pub struct PdfCraftApp { pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Page Properties: which pages, and the tab order chosen for them. + pub page_props_draft: Option, /// Text field table: the table being set up, and the page it goes on. pub field_table_draft: Option, /// Prepare a form ▸ Preview: fill the form instead of editing its fields. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f58a7d972f..7c5b2a03d7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,6 +190,7 @@ const PAGES: &[&str] = &[ "page.crop", "page.boxes", "page.number", + "page.properties", SEP, "bookmark.add", "bookmark.from_structure", diff --git a/crates/ui-egui/src/panels/organize_panel.rs b/crates/ui-egui/src/panels/organize_panel.rs index 338f64bc14..77f1b250d5 100644 --- a/crates/ui-egui/src/panels/organize_panel.rs +++ b/crates/ui-egui/src/panels/organize_panel.rs @@ -159,7 +159,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if row(ui, t, "tag", tl!("Page labels"), Availability::Ready, false).clicked() { act = Some(Act::Command("page.number")); } - row(ui, t, "info", tl!("Page properties"), Availability::Later, false); + if row(ui, t, "info", tl!("Page properties"), Availability::Ready, false).clicked() { + act = Some(Act::Command("page.properties")); + } }); if close { @@ -182,6 +184,30 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } } +/// Page Properties ▸ Tab Order for the pages it opened on: Use Row Order, Use Column Order, Use +/// Document Structure and Unspecified, then OK and Cancel. OK is off until a choice is made. +/// Returns (OK, Cancel). +pub(crate) fn page_props_body(ui: &mut egui::Ui, d: &mut crate::PagePropsDraft) -> (bool, bool) { + use pdfcraft_engine::TabOrder; + ui.label(egui::RichText::new(tl!("Page Properties")).font(theme::semibold(18.0))); + widgets::section_title(ui, tl!("Tab Order")); + for (order, label) in [ + (TabOrder::Row, tl!("Use Row Order")), + (TabOrder::Column, tl!("Use Column Order")), + (TabOrder::Structure, tl!("Use Document Structure")), + (TabOrder::Annotations, tl!("Unspecified")), + ] { + widgets::radio_value(ui, &mut d.tab_order, Some(order), label); + } + let (mut ok, mut cancel) = (false, false); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ok = ui.add_enabled_ui(d.tab_order.is_some(), |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked(); + cancel = widgets::pill_button(ui, tl!("Cancel"), false).clicked(); + }); + (ok, cancel) +} + /// Whether a row works in this build. #[derive(Clone, Copy, PartialEq, Eq)] enum Availability { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index dd3b3e90aa..d41d511a97 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Organize pages ▸ Page properties (the tab order of the selected pages). + PageProps, /// Protect Using Password. Protect, /// Set Page Boxes (crop, trim, bleed, art, media). @@ -213,6 +215,18 @@ pub struct DuplicateDraft { pub to: usize, } +/// Page Properties: the document and pages it applies to (0-based), the document's edit +/// generation when the dialog opened, and the pages' tab order as they have it (`None` when they +/// differ) and as chosen (`None` until one is chosen). +#[derive(Clone, Debug, PartialEq)] +pub struct PagePropsDraft { + pub doc: DocId, + pub generation: u64, + pub pages: Vec, + pub before: Option, + pub tab_order: Option, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e5e104d674..b181bcb22e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6206,6 +6206,125 @@ fn organize_panel_comes_with_the_grid_and_closes_it() { assert!(h.query_by_label("Page properties").is_none(), "the panel is gone"); } +/// Organize pages ▸ Page properties ▸ Tab Order sets the tab order of the selected pages only, +/// as one undo step, and opens on the order the pages have. +#[test] +fn page_properties_sets_the_tab_order_of_the_selected_pages() { + use pdfcraft_engine::TabOrder::{Annotations, Column, Row}; + let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().tab_orders(); + // The undo and redo labels: a new step replaces the redo step an undo left. + let undo = |h: &Harness<'static, PdfCraftApp>| { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + (doc.can_undo().map(str::to_owned), doc.can_redo().map(str::to_owned)) + }; + let mut h = organize(3); + h.get_by_label("Select page 1").click(); + h.run_steps(2); + h.get_by_label("Select page 3").click(); + h.run_steps(2); + h.get_by_label("Page properties").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::PageProps)); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, Some(Annotations), "opens on Unspecified, as the pages have it"); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!(tabs(&h), [Column, Annotations, Column]); + assert_eq!(undo(&h).0.as_deref(), Some("Set tab order")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(tabs(&h), [Annotations; 3], "one undo step"); + + // Pages with different orders open with no choice made, and OK is off until one is. + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetTabOrder { pages: vec![0], order: Row }); + h.run_steps(2); + h.state_mut().views[0].select_pages(&[0, 1]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, None); + assert!(h.get_by_label("OK").accesskit_node().is_disabled()); + h.get_by_label("Use Row Order").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(tabs(&h), [Row, Row, Annotations]); + + // OK on the order the pages already have adds no undo step, and Cancel changes nothing. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + let before = (tabs(&h), undo(&h)); + assert_eq!(before.1.1.as_deref(), Some("Set tab order"), "the undone step can be redone"); + h.state_mut().views[0].select_pages(&[2]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!((tabs(&h), undo(&h)), before); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Document Structure").click(); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + assert_eq!((tabs(&h), undo(&h)), before); +} + +/// Page properties sets the tab order of the document it was opened on: when another document +/// is active by OK, neither document changes. +#[test] +fn page_properties_does_not_apply_to_another_active_document() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let first = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + h.state_mut().open_bytes("other.pdf", None, fixture(3)).unwrap(); + h.run_steps(4); + let other = h.state().views[1].id; + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(other)); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + for id in [first, other] { + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert!(doc.can_undo().is_none()); + } +} + +/// Page properties sets the tab order of the pages it was opened on: when an edit changes the +/// document while the dialog is open, OK closes it without changing a page. +#[test] +fn page_properties_does_not_apply_after_the_document_changes() { + use pdfcraft_engine::TabOrder::Annotations; + let mut h = organize(3); + let id = h.state().views[0].id; + h.state_mut().views[0].select_pages(&[0]); + assert!(h.state_mut().execute("page.properties")); + h.run_steps(3); + h.get_by_label("Use Column Order").click(); + h.run_steps(2); + // Page 1 becomes page 3, so index 0 is another page now. + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.tab_orders(), [Annotations; 3]); + assert_ne!(doc.can_undo(), Some("Set tab order")); + assert_eq!( + h.state().toast.as_ref().map(|t| t.0.as_str()), + Some("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.") + ); +} + /// Leaving Organize (a page opened from the grid, or the grid's Close) goes back to the /// workspace's panel: under the Edit tab, the Edit panel, not All tools. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d9fbbd1291..15a2ed7d8d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2656,7 +2656,7 @@ status = "shipped" commands = ["page.organize"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/it/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/it/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/it/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/it/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] -notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages, Page labels and Page properties (the selected pages' tab order). Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions and Page templates are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -3240,11 +3240,11 @@ title = "Page tab order property" area = "D" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.properties"] +tools = ["form_tab_order"] +evidence = ["crates/ui-egui/tests/it/editing.rs::page_properties_sets_the_tab_order_of_the_selected_pages", "crates/forms/src/tests.rs::tab_orders_are_read_for_each_page", "crates/forms/src/tests.rs::rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right", "crates/forms/src/tests.rs::an_indirect_tabs_value_orders_the_page", "crates/automation/tests/it/automation.rs::page_tab_orders_are_read_and_set_per_page_through_tools"] +notes = "Organize pages ▸ Page properties ▸ Tab Order sets the selected pages' order: Use Row Order, Use Column Order, Use Document Structure or Unspecified, as one undo step. It opens on the pages' order, or with no choice when they differ. Rows and columns run right to left in a document bound on the right. form_tab_order reads each page's order (page_tabs) and sets it for chosen pages. Acrobat's Page Properties also has an Actions tab (organize.page-actions), still planned." [[feature]] id = "organize.page-actions" From 222a124650b17816ff240215987a4441bcb4b992 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 28 Oct 2026 14:53:03 -0700 Subject: [PATCH 66/75] M5: Import comments takes another PDF and copies its comments onto the same pages (#4595) * M5: Import comments takes another PDF and copies its comments onto the same pages Import comments read only XFDF, FDF, XML, XDP, CSV and text data files. It now also takes a PDF: each comment of that PDF is copied onto the page with the same number, at the same place, with its appearance stream, its pop-up, its replies and its layer. Comments on pages past the document's last page are left out, together with replies to them. Links, form fields and redaction marks are not copied. A comment named like one already on its page replaces it, as an XFDF or FDF import does. A PDF without comments is refused with "nothing in the file matches this document", and a PDF that needs a password is refused. - organize: import_comments copies the comments with the page copier that Insert Pages uses, and registers the layers they use. - engine: Edit::ImportData reads a file that starts with %PDF- as a PDF. Any other file goes to the data importer first, and is read as a PDF when that fails, %PDF- occurs in its first 1,024 bytes and the PDF reader opens it. - ui: Comments > Import comments lists .pdf files; form.import_data lists the same files as before. - automation: the doc_import_data description names the PDF case. - parity: comment.import-from-pdf is shipped. * Import comments: an annotation that is no comment keeps its name A comment imported from a PDF replaced any annotation of the same /NM on its page that was not locked, a redaction mark or a widget, so a link, screen or 3D annotation of that name was overwritten. Only an annotation whose subtype is one of the copied comment subtypes is replaced now; for any other, the copy is added beside it without the name. The subtype is read through an indirect reference. * Import comments: index each page's annotations instead of scanning them per comment * Import comments: a layer that BaseState OFF hides stays hidden * Import comments: a PDF with binary bytes before its header imports on an XFA form, and two comments with one name both land On an XFA form, a decode failure no longer stops the import when a PDF header is in the first 1,024 bytes; the PDF's comments are copied. Two incoming comments on one page with the same /NM no longer replace the same comment: the first replaces it, and the second is added without the name. * Import comments: an inline annotation on the page is replaced by name An annotation written inline in a page's /Annots is made an indirect object before names are matched, so an incoming comment with its /NM replaces it instead of being added beside it. * Import comments: two new comments with one name keep it once When no comment on the page has the name, the first incoming comment keeps its /NM and each later one with the same name is added without it. --- crates/automation/src/tools.rs | 2 +- crates/engine/src/edit.rs | 3 +- crates/engine/src/edit/run.rs | 24 +- crates/engine/src/sources.rs | 18 + crates/engine/src/tests.rs | 64 +++ crates/organize/src/comments.rs | 657 ++++++++++++++++++++++++++ crates/organize/src/import.rs | 18 +- crates/organize/src/import/copy.rs | 12 +- crates/organize/src/lib.rs | 2 + crates/ui-egui/src/commands.rs | 3 +- crates/ui-egui/src/workflows/files.rs | 25 +- crates/ui-egui/tests/it/pickers.rs | 39 ++ parity/acrobat-features.toml | 10 +- 13 files changed, 849 insertions(+), 28 deletions(-) create mode 100644 crates/organize/src/comments.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c67c2ecdbe..f6e60135cd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -511,7 +511,7 @@ pub fn tools() -> Vec { t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), - t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") + t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 2c64f7bcd6..683f2abe8f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -522,7 +522,8 @@ pub enum Edit { links: Vec<(usize, Vec<[f64; 4]>, String)>, style: LinkStyle, }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the + /// comments of another PDF onto the pages with the same numbers. ImportData { name: String, bytes: Arc>, diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 1d02a258a8..d80ea17a13 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -2,6 +2,8 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; +use crate::SourceProblem; +use crate::sources::{comment_source, import_pdf_comments}; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { @@ -664,7 +666,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); } } - Edit::ImportData { bytes, .. } => { + Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => { + let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?; + import_pdf_comments(doc, &src)?; + } + Edit::ImportData { name, bytes } => { // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; // XFDF, FDF, Acrobat's XML and tables go the usual way. let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); @@ -678,9 +684,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); xfa_after_data_change(doc, cx, true)?; } - Some(Err(why)) => return Err(EditError::Invalid(why)), + Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)), _ => { - pdfcraft_xfdf::import(doc, bytes)?; + // A PDF with bytes before its header, within the 1,024 the reader allows, is read + // as a PDF when it is not a data file. A file the PDF reader cannot open keeps + // the data importer's error. + let mut data = doc.clone(); + match pdfcraft_xfdf::import(&mut data, bytes) { + Ok(_) => *doc = data, + Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) { + Ok(src) => import_pdf_comments(doc, &src)?, + Err(SourceProblem::Unreadable(_)) => return Err(e.into()), + Err(p) => return Err(EditError::Source(format!("{name}: {p}"))), + }, + Err(e) => return Err(e.into()), + } } } } diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 3c8083162e..91d6efa3ee 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// Import the comments of `src` onto the pages of `doc` with the same numbers. +pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { + if pdfcraft_organize::import_comments(doc, src)? == 0 { + return Err(pdfcraft_xfdf::DataError::NothingImported.into()); + } + Ok(()) +} + +/// Parse another PDF to import comments from, whatever its permissions allow. +pub(crate) fn comment_source(bytes: &Arc>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) { + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ecaf82efc3..482868b55e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8897,6 +8897,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); } +/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses +/// a PDF that has none. +#[test] +fn importing_a_pdf_copies_its_comments() { + let mut s = Session::new(); + let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap(); + let review = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + ]); + s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap(); + let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::>(); + assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link"); + assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf"); + assert!(s.get(id).unwrap().info.annotations.is_empty()); + + // Bytes before the header, as the reader allows them. + let prefixed = [b"\r\n \n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + assert_eq!(contents(&s), [Some("from the review".to_string())]); + s.undo(id).unwrap(); + + let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]); + let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err(); + assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string()); + // A PDF that needs a password is refused with that reason, with or without bytes before its header. + let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap(); + locked + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap(); + for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] { + let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err(); + assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string()); + } + + // An XFDF file that mentions the PDF header is still read as XFDF. + let xfdf = r#"from the data file"#; + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(contents(&s), [Some("from the data file".to_string())]); +} + +/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of +/// refusing it as a data file in an unknown encoding. +#[test] +fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() { + let data = "
JP
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]); + let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}"); +} + /// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). fn pdf_with_annots(annots: &[&[u8]]) -> Vec { let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs new file mode 100644 index 0000000000..dd5d63ebc4 --- /dev/null +++ b/crates/organize/src/comments.rs @@ -0,0 +1,657 @@ +//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF +//! chosen): each comment is copied onto the page with the same number, at the same place, with +//! its appearance, pop-up and replies. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::import::{Copier, register_layers}; +use crate::{OrganizeError, walk}; + +/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171, +/// except redaction marks (`Redact`) and `Projection`. +const COMMENTS: [&[u8]; 16] = [ + b"Text", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Stamp", + b"Caret", + b"Ink", + b"FileAttachment", + b"Sound", +]; + +/// The Locked annotation flag. +const LOCKED: i64 = 128; + +/// One comment of the source: its entry in the page's `/Annots` and its dictionary. +struct Incoming { + entry: Object, + dict: Dict, +} + +/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of +/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the +/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its +/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are +/// not copied. +/// +/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object +/// so replies to it stay attached. A locked comment, or an annotation of that name that is no +/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it +/// without the name. Returns the number of comments copied. +pub fn import_comments(dst: &mut Document, src: &Document) -> Result { + let from = walk(src)?; + let to = walk(dst)?; + let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); + let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); + let kept = threads_kept(&incoming); + for list in &mut incoming { + list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + } + let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); + // Every comment's object is chosen before any is copied, so a comment that another one reaches + // through `/IRT` is copied into the object chosen for it by name. + let mut lists: Vec> = Vec::new(); + let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new(); + for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() { + if list.is_empty() { + lists.push(None); + continue; + } + let mut annots = PageAnnots::read(dst, *page); + for Incoming { entry, dict } in list { + // The same comment listed twice on a page is copied once. + if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) { + continue; + } + let (target, dict) = reserve(dst, src, &mut annots, dict); + if let Some(r) = entry.as_ref() { + copier.map.insert(r, target); + } + annots.push(target); + planned.push((i, target, dict)); + } + lists.push(Some(annots)); + } + let mut annot_pages = HashMap::new(); + for (i, target, dict) in &planned { + let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?; + dst.set(*target, copy); + annot_pages.insert(*target, pages[*i].1); + } + for (i, target, _) in &planned { + if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup")) + && let Some(annots) = lists[*i].as_mut() + { + annots.push(popup); + annot_pages.insert(popup, pages[*i].1); + } + } + // A pop-up that only its `/Parent` ties to a copied comment comes along too. + for (i, (from, to)) in pages.iter().enumerate() { + let Some(annots) = lists[i].as_mut() else { continue }; + for (popup, parent) in page_popups(src, *from) { + if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) { + continue; + } + let r = dst.add(Object::Null); + copier.map.insert(popup, r); + let copy = copier.copy_value(dst, &src.get(popup), Some(r))?; + dst.set(r, copy); + annots.push(r); + annot_pages.insert(r, *to); + } + } + for ((_, page), annots) in pages.iter().zip(lists) { + if let Some(annots) = annots { + dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?; + } + } + copier.fix_annotations(dst, &annot_pages)?; + register_layers(dst, src, &copier.ocgs)?; + Ok(planned.len()) +} + +/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new +/// object. Returns it with the dictionary to copy. +fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) { + let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text())); + let same = nm.as_deref().and_then(|nm| annots.named(dst, nm)); + // A second incoming comment with the name of one added earlier is added without the name. + let taken = nm.is_some_and(|nm| !annots.added.insert(nm)); + let target = match same { + None if taken => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + Some((r, false)) => { + annots.claimed.insert(r); + // The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent` + // is the comment. + if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) { + annots.remove(p); + } + for p in annots.popups.get(&r).cloned().unwrap_or_default() { + annots.remove(p); + } + r + } + Some((_, true)) => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + None => dst.add(Object::Null), + }; + (target, dict) +} + +/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one +/// only with the comment it answers, up to the first comment of the thread. A thread whose replies +/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. +fn threads_kept(incoming: &[Vec]) -> HashSet { + // For each comment: no parent, a parent comment, or a parent that is no comment of the source. + let parents: HashMap>> = + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + let mut known: HashMap = HashMap::new(); + for &start in parents.keys() { + let (mut path, mut on_path) = (Vec::new(), HashSet::new()); + let mut at = start; + let kept = loop { + if let Some(&k) = known.get(&at) { + break k; + } + if !on_path.insert(at) { + break true; + } + match parents.get(&at) { + None => break false, + Some(None) => { + path.push(at); + break true; + } + Some(Some(None)) => { + path.push(at); + break false; + } + Some(Some(Some(parent))) => { + path.push(at); + at = *parent; + } + } + }; + for r in path { + known.insert(r, kept); + } + } + known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect() +} + +/// The comments in `page`'s `/Annots`, in order. +fn page_comments(src: &Document, page: ObjRef) -> Vec { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .into_iter() + .filter_map(|entry| { + let dict = src.resolve(&entry).as_dict().cloned()?; + let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) + }) + .collect() +} + +/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`). +fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .iter() + .filter_map(|entry| { + let r = entry.as_ref()?; + let d = src.get(r).as_dict().cloned()?; + (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + }) + .collect() +} + +/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it +/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a +/// redaction mark or a form widget. +/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not +/// scan the whole list. +struct PageAnnots { + list: Vec, + members: HashSet, + /// The annotations on the page before the import, by `/NM`, in list order. + names: HashMap>, + /// The pop-ups on the page before the import, by `/Parent`. + popups: HashMap>, + removed: HashSet, + /// The annotations that an incoming comment has already replaced. + claimed: HashSet, + /// The names of the incoming comments placed so far. + added: HashSet, +} + +impl PageAnnots { + /// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects. + fn read(dst: &mut Document, page: ObjRef) -> Self { + let list: Vec = dst + .get(page) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| dst.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let list: Vec = list + .into_iter() + .map(|a| match a { + Object::Dict(_) => Object::Ref(dst.add(a)), + a => a, + }) + .collect(); + let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::>::new(), HashMap::>::new()); + for r in list.iter().filter_map(Object::as_ref) { + members.insert(r); + let Some(d) = dst.get(r).as_dict().cloned() else { continue }; + if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { + names.entry(name).or_default().push(r); + } + if d.name(b"Subtype") == Some(b"Popup".as_slice()) + && let Some(parent) = d.reference(b"Parent") + { + popups.entry(parent).or_default().push(r); + } + } + PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() } + } + + /// Add `r` at the end, unless the page already lists it. + fn push(&mut self, r: ObjRef) { + if self.removed.remove(&r) { + self.list.retain(|a| a.as_ref() != Some(r)); + } + if self.members.insert(r) { + self.list.push(Object::Ref(r)); + } + } + + /// Take every entry for `r` off the page. + fn remove(&mut self, r: ObjRef) { + if self.members.remove(&r) { + self.removed.insert(r); + } + } + + /// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or + /// it is no comment. + fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> { + let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r)); + let first = *listed.clone().next()?; + // A second incoming comment with the name of one already replaced is added beside it. + let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) }; + let d = dst.get(r).as_dict().cloned()?; + let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0); + let subtype = d.get(b"Subtype").map(|s| dst.resolve(s)); + let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s)); + Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT"))) + } + + /// The new `/Annots`. + fn into_list(self) -> Vec { + let removed = self.removed; + self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{SaveOptions, write_full}; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + /// The reviewer's copy: three pages of comments, a link and a pop-up among them. + fn reviewed() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>", + "<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream", + "<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>", + ]) + } + + /// The document receiving the comments: two pages, three comments of its own on page 1. + fn original() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>", + ]) + } + + fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> { + let (p, _) = walk(doc).unwrap()[page].clone(); + let list = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default(); + list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect() + } + + fn text(d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text()) + } + + fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) { + list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}")) + } + + #[test] + fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() { + let mut doc = original(); + let src = reviewed(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2"); + let pages: Vec = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect(); + let one = page_annots(&doc, 0); + for (_, d) in &one { + assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on"); + } + + // The note named like one already there replaces it in place, with its new pop-up. + let (note, d) = by_contents(&one, "from the reviewer"); + assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object"); + let popup = d.reference(b"Popup").unwrap(); + assert_ne!(popup, ObjRef::new(8, 0)); + assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note)); + let popups: Vec = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect(); + assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once"); + assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note"); + + // The locked comment of that name stays; the copy is added beside it, unnamed. + assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked")); + assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None); + assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay"); + + // The stamp keeps its appearance; the redaction mark is not copied. + let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap(); + let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied"); + assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice()))); + + // Page 3 is past the document's last page: its square and the reply to it are left out. + assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3"))); + assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up"); + + // Page 2 takes the highlight, not the link. + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 1); + assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice())); + assert_eq!(two[0].1.reference(b"P"), Some(pages[1])); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same"); + } + + #[test] + fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() { + // Page 1 holds a reply to a note on page 2, named like the document's own note there. + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>", + // A pop-up that only its /Parent ties to the note. + "<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + // The same name in UTF-16BE, and a pop-up the note does not name. + "<< /Type /Annot /Subtype /Text /NM /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes"); + assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer"))); + assert_ne!(two[1].0, ObjRef::new(6, 0)); + assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0))); + assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true))); + assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0)); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1); + assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note"); + } + + #[test] + fn a_comment_keeps_its_layer_and_the_layer_its_visibility() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)]))); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + } + + #[test] + fn a_layer_hidden_by_the_base_state_stays_hidden() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![]))); + } + + #[test] + fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() { + // Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3, + // which the document has no page for. Then two replies that answer each other. + let n = 20_000; + let first = 6; + let mut bodies: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()]; + let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect(); + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>")); + bodies.push("<< /Type /Page /Parent 2 0 R >>".into()); + let square = first + n + 2; + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>")); + for o in first..first + n { + let parent = if o + 1 == first + n { square } else { o + 1 }; + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>")); + } + let (a, b) = (first + n, first + n + 1); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into()); + let src = build(&bodies.iter().map(String::as_str).collect::>()); + let mut doc = original(); + let started = std::time::Instant::now(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + let one = page_annots(&doc, 0); + let (one_ref, one_dict) = by_contents(&one, "one"); + let (two_ref, two_dict) = by_contents(&one, "two"); + assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref))); + } + + #[test] + fn only_a_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>", + ]); + // A link and a screen annotation named like two of the comments, and a comment whose + // subtype is an indirect object. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>", + "/Text", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 3); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced"); + let (note, d) = by_contents(&one, "newer text"); + assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place"); + assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice()))); + assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1")); + assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice()))); + assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1")); + assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name"); + assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None); + } + + #[test] + fn two_comments_with_one_name_replace_one_comment_and_add_the_other() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2, "one comment replaced and one added"); + let (first, d) = by_contents(&one, "first"); + assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin"))); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn two_new_comments_with_one_name_keep_it_once() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2); + assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin")); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn an_inline_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 1); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy"); + assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline")); + } + + #[test] + fn a_pdf_without_comments_copies_none() { + let mut doc = original(); + let before = page_annots(&doc, 0).len(); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); + assert_eq!(page_annots(&doc, 0).len(), before); + } +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index bd1878c3bc..9f86d6455a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -34,8 +34,8 @@ pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, }; -pub(crate) use copy::copy_object; -use copy::{Copier, SKIP_ON_PAGE}; +use copy::SKIP_ON_PAGE; +pub(crate) use copy::{Copier, copy_object}; use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; pub use fields::{combine_field_conflicts, field_name_conflicts}; @@ -432,18 +432,22 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: /// Register copied optional content groups in the destination's `/OCProperties`, keeping each /// layer's default visibility from the source. -fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { +pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { if ocgs.is_empty() { return Ok(()); } - let src_off: Vec = src + let src_config: Dict = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o))) .and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d))) - .and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o))) - .and_then(|o| o.as_array().cloned()) + .and_then(|d| d.as_dict().cloned()) .unwrap_or_default(); + let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF")); + // With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101). + let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice())); + let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r)); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; let (props_ref, mut props) = match catalog.get(b"OCProperties") { @@ -462,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] } all.push(r.clone()); order.push(r.clone()); - if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } + if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } } props.set(b"OCGs".to_vec(), Object::Array(all)); config.set(b"ON".to_vec(), Object::Array(on)); diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f4b5499625..5e4be54150 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -43,11 +43,11 @@ pub(super) const WIDGET_KEYS: &[&[u8]] = &[ b"AF", ]; -pub(super) struct Copier<'a> { +pub(crate) struct Copier<'a> { pub(super) src: &'a Document, /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. depth: usize, - pub(super) map: HashMap, + pub(crate) map: HashMap, /// Source page → destination page, for rewriting `/P` and destinations. pub(super) pages: HashMap, /// Destination objects that are annotation dictionaries (fixed up after copying). @@ -55,11 +55,11 @@ pub(super) struct Copier<'a> { /// Top-level form fields reached through widgets. pub(super) fields: Vec, /// Optional content groups copied: (source, destination). - pub(super) ocgs: Vec<(ObjRef, ObjRef)>, + pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, } impl<'a> Copier<'a> { - pub(super) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } } @@ -89,7 +89,7 @@ impl<'a> Copier<'a> { Ok(()) } - pub(super) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { return Ok(o.clone()); } @@ -163,7 +163,7 @@ impl<'a> Copier<'a> { } /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - pub(super) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { for &a in &std::mem::take(&mut self.annots) { let src_dict = dst.get(a).as_dict().cloned(); let Some(d) = src_dict else { continue }; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index e24056a9c3..b890965d0c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -15,6 +15,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; mod attachments; mod boxes; +mod comments; mod dedupe; mod import; mod labels; @@ -27,6 +28,7 @@ mod xmp; pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; +pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 07a36ac7f4..d8c51b0e97 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -543,7 +543,8 @@ impl PdfCraftApp { } "edit.links_from_urls" => self.links_from_urls(), "edit.remove_links" => self.remove_all_links(), - "comment.import" | "form.import_data" => self.import_data_dialog(), + "comment.import" => self.import_comments_dialog(), + "form.import_data" => self.import_data_dialog(), "comment.stamp" => { self.left = crate::LeftPanel::Tool("stamp"); self.reveal_tools(); diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index 0b031c8a8a..a9313a4714 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -868,10 +868,24 @@ pub(crate) fn strip_pdf(name: &str) -> &str { } impl PdfCraftApp { - /// Comments ▸ Import comments / Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text. + /// Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text. pub fn import_data_dialog(&mut self) { + self.import_dialog(false); + } + + /// Comments ▸ Import comments: the files Import data takes, or another PDF to copy the + /// comments of. + pub fn import_comments_dialog(&mut self) { + self.import_dialog(true); + } + + fn import_dialog(&mut self, pdf: bool) { #[cfg(not(target_arch = "wasm32"))] { + let mut extensions = vec!["xfdf", "fdf", "xml", "xdp", "csv", "txt"]; + if pdf { + extensions.insert(2, "pdf"); + } let import = |app: &mut Self, path: std::path::PathBuf| match std::fs::read(&path) { Ok(bytes) => { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -880,11 +894,11 @@ impl PdfCraftApp { Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), }; match self.save_override.clone() { - Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), + Some(p) if extensions.iter().any(|e| p.ends_with(&format!(".{e}"))) => import(self, p.into()), Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"]) + .add_filter(tl!("Comment and form data").to_string(), &extensions) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -892,7 +906,10 @@ impl PdfCraftApp { } } #[cfg(target_arch = "wasm32")] - self.notify_tr("Importing data arrives on the web with file pickers for data files"); + { + let _ = pdf; + self.notify_tr("Importing data arrives on the web with file pickers for data files"); + } } /// Export all comments / form data: the format follows the file name's extension. diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index f9e46c5f2f..330cd50667 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -376,6 +376,45 @@ fn export_comments_writes_on_a_later_frame() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn import_comments_takes_the_comments_of_another_pdf() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + ]; + let mut review = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(review.len()); + review.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = review.len(); + review.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + review.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + review.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = scratch("import-pdf-comments"); + let path = dir.join("review.pdf"); + std::fs::write(&path, review).unwrap(); + let mut h = harness(&[1]); + // Through the save path a test uses in place of the picker, and through the picker. + for chosen in [Some(path.to_string_lossy().into_owned()), None] { + h.state_mut().save_override = chosen.clone(); + h.state_mut().pick_override = chosen.is_none().then(|| vec![path.to_string_lossy().into_owned()]); + h.state_mut().run_command("comment.import"); + h.run_steps(2); + let app = h.state(); + let contents: Vec> = app.session.get(app.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert_eq!(contents, [Some("from the review".to_string())], "the review's comment is imported once ({chosen:?})"); + h.state_mut().run_command("edit.undo"); + h.run_steps(1); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn extracted_pages_are_not_deleted_if_the_document_was_edited_while_choosing_the_folder() { let dir = scratch("extract-edited"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabeb5421a..7e96dfa190 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4264,11 +4264,11 @@ title = "Import comments from another PDF" area = "E" tier = "P2" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.import"] +tools = ["doc_import_data"] +evidence = ["crates/organize/src/comments.rs::comments_of_another_pdf_land_on_the_pages_with_the_same_numbers", "crates/organize/src/comments.rs::a_reply_on_an_earlier_page_reaches_its_comment_by_name", "crates/organize/src/comments.rs::a_comment_keeps_its_layer_and_the_layer_its_visibility", "crates/organize/src/comments.rs::a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays", "crates/organize/src/comments.rs::a_pdf_without_comments_copies_none", "crates/engine/src/tests.rs::importing_a_pdf_copies_its_comments", "crates/ui-egui/tests/it/pickers.rs::import_comments_takes_the_comments_of_another_pdf"] +notes = "Import comments accepts a PDF beside XFDF and FDF. Each comment goes onto the page with the same number, at the same place, with its appearance, pop-up and replies; comments on pages past the document's last page are left out. A same-named comment is replaced, as with XFDF and FDF. Not yet: redaction marks are not copied, and a PDF that needs a password is refused." [[feature]] id = "comment.summarize" From ba09e1aefcc28ce42206dc3ad70a24975bfbfb24 Mon Sep 17 00:00:00 2001 From: jpeskov <101976824+jpeskov@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:34:21 +0530 Subject: [PATCH 67/75] fix(render): an isolated DeviceCMYK transparency group blends in CMYK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO 32000-2 §11.3.4: the elements of a transparency group blend in the group's colour space. For a subtractive space, each component is complemented before the blend function and again after it. §11.3.5.3: the nonseparable modes blend C, M and Y as their complements R, G and B, and K comes from the backdrop (Hue, Saturation, Color) or from the source (Luminosity). PdfCraft converted each colour to RGB and blended in RGB. A print test file's blend-mode patch then showed an X in almost every cell: magenta under black with Difference stayed magenta, not green. hayro-interpret patch (45): CmykGroup and Device::draw_isolated_cmyk_group (default false) for isolated, non-knockout groups with /CS /DeviceCMYK. interpret_form_contents is now a function of its own. Color::ink_tints and color::cmyk_to_srgb8 are new. hayro patch (21): the renderer draws such a group as a CMY pass and a K pass of complemented tints. It converts the tints to RGB with the CMYK profile and composites the result through the group's layer. The group is drawn as before with reading colours, for contents without process tints (RGB, spot colours, patterns, images) and above 2^24 pixels. Test: raster::tests::isolated_groups_blend_in_device_cmyk. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 4 + .../src/raster/tests/isolated_cmyk_groups.rs | 97 +++++++++ vendor/README.md | 4 +- vendor/hayro-interpret/src/cmyk_group.rs | 95 +++++++++ vendor/hayro-interpret/src/color.rs | 36 ++++ vendor/hayro-interpret/src/device.rs | 8 + vendor/hayro-interpret/src/lib.rs | 2 + vendor/hayro-interpret/src/x_object.rs | 59 ++++-- vendor/hayro/src/renderer.rs | 188 ++++++++++++++++++ 9 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 crates/render/src/raster/tests/isolated_cmyk_groups.rs create mode 100644 vendor/hayro-interpret/src/cmyk_group.rs diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 36299d74fd..5b4d1036d5 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7004,6 +7004,10 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; +// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 45, hayro +// patch 21). +mod isolated_cmyk_groups; + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs new file mode 100644 index 0000000000..0d6294e379 --- /dev/null +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -0,0 +1,97 @@ +//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2 +//! §11.3.4, §11.3.5.3). + +use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; +use std::sync::Arc; + +/// A print test file's patch of blend modes in isolated groups whose colour space is +/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the +/// group's colour space, and a subtractive space's components are complemented before the +/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes, +/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop +/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so +/// magenta under black with Difference came out magenta instead of green. Vendored +/// hayro-interpret patch (45) and hayro patch (21): an isolated group (§11.4.7: its own +/// transparent backdrop) blending in DeviceCMYK is blended in CMYK. +/// +/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated +/// group on it whose two elements are tint A and then tint B with the cell's blend mode: +/// drawn right, the group is the colour of the square around it. +#[test] +fn isolated_groups_blend_in_device_cmyk() { + // (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand) + type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]); + let cells: [Cell; 7] = [ + // |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green. + ("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]), + ("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]), + ("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + ("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + ("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + // Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855); + // K is the backdrop's. + ("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]), + // (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's. + ("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]), + ]; + let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]); + // `group`: the cell group's /Group entries; `b`: what the second element paints. + let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| { + let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new()); + for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() { + let x = 10 + i * 50; + page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected)); + let rect = format!("{} 20 20 20 re f", x + 10); + let n = 10 + i * 3; + for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] { + let body = format!("{paint} {rect}"); + objects += &format!( + "{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n", + x + 40, + body.len() + ); + } + let body = "/E1 Do /BM gs /E2 Do"; + objects += &format!( + "{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n", + n + 2, + x + 40, + n + 1, + body.len() + ); + names += &format!("/G{i} {} 0 R ", n + 2); + } + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +{objects}trailer << /Root 1 0 R >> +%%EOF", + page.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{group}: {:?}", p.error); + p + }; + let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec(); + let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + + let p = render("/CS /DeviceCMYK /I true", &tint); + for (i, (mode, ..)) in cells.iter().enumerate() { + let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30)); + // The square is a solid CMYK fill; the group lands within rounding of it. + assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}"); + } + + // A group whose contents have no ink tints (here the second element is RGB) is + // composited in RGB exactly as before: the same as a group blending in DeviceRGB. + let rgb = |_: [f32; 4]| "1 0 0 rg".to_string(); + let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb); + let rgb_group = render("/CS /DeviceRGB /I true", &rgb); + assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before"); +} diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..73140ab8d7 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, four-component ICC unconverted, DeviceGray as black alone, §10.4.2.2) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell) | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk` | +| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (44)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk` | | 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/cmyk_group.rs b/vendor/hayro-interpret/src/cmyk_group.rs new file mode 100644 index 0000000000..1135171c9f --- /dev/null +++ b/vendor/hayro-interpret/src/cmyk_group.rs @@ -0,0 +1,95 @@ +//! PdfCraft patch (45): an isolated transparency group that blends in `DeviceCMYK`. +//! +//! ISO 32000-2 §11.3.4: a group's elements blend in the group's colour space, and the +//! components of a subtractive space are complemented before the blend function and +//! complemented back after it. The renderer composites in RGB, which gives other colours +//! wherever a blend mode meets CMYK artwork: `0 1 0 0 k` (magenta) under `0 0 0 1 k` (black) +//! with `/BM /Difference` is green when blended in CMYK (`|(1,0,1,1) − (1,1,1,0)|` +//! complemented back is `1 0 1 0`) and magenta when blended in RGB. +//! +//! An *isolated* group starts from a transparent backdrop of its own (§11.4.7), so its +//! interior can be computed apart from the page and handed back as finished pixels. A device +//! that can blend in CMYK takes such a group through [`Device::draw_isolated_cmyk_group`] and +//! interprets it with [`CmykGroup::interpret`] as often as it needs; every other device, and a +//! device that declines, gets the group's contents as before. + +use crate::InterpreterSettings; +use crate::context::{Context, InterpreterCache}; +use crate::device::Device; +use crate::interpret::state::{ClipType, State}; +use crate::x_object::{FormXObject, interpret_form_contents}; +use hayro_syntax::object::dict::keys::{CS, GROUP}; +use hayro_syntax::object::{Dict, Name}; +use hayro_syntax::page::Resources; +use hayro_syntax::xref::XRef; +use kurbo::{Affine, Rect, Shape}; +use std::ops::Deref; + +/// An isolated, non-knockout transparency group whose blending colour space is `DeviceCMYK`, +/// captured where it is painted (PdfCraft patch (45); see the module documentation). +pub struct CmykGroup<'a, 'b> { + form: &'b FormXObject<'a>, + resources: &'b Resources<'a>, + /// The graphics state the group's contents start with: the state at `Do`, after the + /// form's `/Matrix`, with the group's own opacity, soft mask and blend mode already taken + /// by the layer the group is composited through. + state: State<'a>, + root_transform: Affine, + bbox: Rect, + interpreter_cache: InterpreterCache<'a>, + settings: InterpreterSettings, + xref: &'a XRef, + nesting_depth: u32, +} + +impl<'a, 'b> CmykGroup<'a, 'b> { + pub(crate) fn new(form: &'b FormXObject<'a>, resources: &'b Resources<'a>, context: &Context<'a>) -> Self { + let mut state = context.get().clone(); + // The clips in force belong to the device the group is composited onto; contents + // interpreted on their own push and pop only the clips they set themselves. + state.clips = Vec::::new(); + Self { + form, + resources, + state, + root_transform: context.root_transform(), + bbox: context.bbox(), + interpreter_cache: context.interpreter_cache.clone(), + settings: context.settings.clone(), + xref: context.xref, + nesting_depth: context.nesting_depth(), + } + } + + /// The device-space box the group can paint: its `/BBox` under the current transform, + /// within the area being drawn. + pub fn device_bbox(&self) -> Rect { + let [x0, y0, x1, y1] = self.form.bbox; + let user = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64); + let device = (self.state.ctm * user.to_path(0.1)).bounding_box(); + device.intersect(self.bbox) + } + + /// Interpret the group's contents into `device`, with device space moved by `offset` + /// (so a device covering only [`Self::device_bbox`] can start at its corner). + pub fn interpret(&self, device: &mut impl Device<'a>, offset: (f64, f64)) { + let shift = Affine::translate((-offset.0, -offset.1)); + let mut state = self.state.clone(); + state.ctm = shift * state.ctm; + let mut context = Context::new_with( + shift * self.root_transform, + shift.transform_rect_bbox(self.bbox), + &self.interpreter_cache, + self.xref, + self.settings.clone(), + state, + self.nesting_depth, + ); + interpret_form_contents(self.resources, self.form, &mut context, device); + } +} + +/// Whether `form`'s transparency group blends in `DeviceCMYK`: it says so in its `/CS`. +pub(crate) fn blends_in_device_cmyk(form: &Dict<'_>) -> bool { + form.get::>(GROUP).and_then(|g| g.get::>(CS)).is_some_and(|cs| matches!(cs.deref(), b"DeviceCMYK" | b"CMYK")) +} diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 531e3b01c9..348dd51758 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1399,6 +1399,32 @@ impl Color { .to_rgba(&self.components, self.opacity, false) } + /// PdfCraft patch (45): the colour as process ink tints `[C, M, Y, K]` (0 = no ink) and + /// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4). + /// + /// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray` + /// (§10.4.2.2). Four-component ICC colour is taken as the tints it names, unconverted. + /// `None` for every other space: such a colour has no tints without a colour conversion + /// this crate does not make, and the caller draws it as before. + pub fn ink_tints(&self) -> Option<([f32; 4], f32)> { + let c = |i: usize| { + self.components + .get(i) + .copied() + .unwrap_or(0.0) + .clamp(0.0, 1.0) + }; + let tints = match self.color_space.0.as_ref() { + ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)], + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => { + [c(0), c(1), c(2), c(3)] + } + ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)], + _ => return None, + }; + Some((tints, self.opacity.clamp(0.0, 1.0))) + } + /// Create a color from RGBA. pub fn from_rgba(rgba: AlphaColor) -> Self { let c = rgba.components(); @@ -1410,6 +1436,16 @@ impl Color { } } +/// PdfCraft patch (45): `input`, 8-bit `DeviceCMYK` pixels (four bytes each), converted to +/// 8-bit sRGB in `output` (three bytes each) through the CMYK profile pages are drawn with, +/// so a colour blended in CMYK lands where the same colour painted directly does. +pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> { + if input.len() % 4 != 0 || output.len() != input.len() / 4 * 3 { + return None; + } + CMYK_TRANSFORM.convert_u8(input, output) +} + static CMYK_TRANSFORM: LazyLock = LazyLock::new(|| { ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4).unwrap() }); diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index f85969dff3..4aea7ad2c0 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -1,3 +1,4 @@ +use crate::cmyk_group::CmykGroup; use crate::font::Glyph; use crate::soft_mask::SoftMask; use crate::{BlendMode, ClipPath, Image}; @@ -107,6 +108,13 @@ 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 (45): draw the contents of an isolated transparency group that blends + /// in `DeviceCMYK` (ISO 32000-2 §11.3.4), already inside the layer it is composited + /// through. Return `true` when the device drew them; on `false` (the default) the + /// interpreter draws the contents into the device as for any other group. + fn draw_isolated_cmyk_group(&mut self, _group: &CmykGroup<'a, '_>) -> bool { + false + } } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/lib.rs b/vendor/hayro-interpret/src/lib.rs index 24c3be2957..01e717da93 100644 --- a/vendor/hayro-interpret/src/lib.rs +++ b/vendor/hayro-interpret/src/lib.rs @@ -32,6 +32,7 @@ This crate has one optional feature: mod log; mod cache; +mod cmyk_group; mod context; mod convert; mod device; @@ -53,6 +54,7 @@ pub mod shading; pub mod util; pub use cache::CacheKey; +pub use cmyk_group::CmykGroup; pub use context::*; pub use device::*; pub use function::Function; diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 304b14a085..df192ee973 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,4 +1,5 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; +use crate::cmyk_group::{CmykGroup, blends_in_device_cmyk}; use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb}; use crate::context::Context; use crate::device::Device; @@ -138,8 +139,6 @@ pub(crate) fn draw_form_xobject<'a, 'b>( return; } - let iter = TypedIter::new(x_object.decoded.as_ref()); - context.path_mut().truncate(0); context.save_state(); context.pre_concat_affine(x_object.matrix); @@ -210,6 +209,45 @@ pub(crate) fn draw_form_xobject<'a, 'b>( } } + let resources = Resources::for_form(x_object.resources.clone(), resources.clone()); + + // PdfCraft patch (45): an isolated group blending in DeviceCMYK starts from a backdrop of + // its own, so a device able to blend in CMYK computes its interior apart and composites the + // result through the layer pushed above (see `cmyk_group`). Knockout groups are left out: + // their elements composite with the group's initial backdrop, not with each other. + let blended_in_cmyk = own_layer + && x_object.is_isolated + && !x_object.is_knockout + && blends_in_device_cmyk(&x_object.dict) + && device.draw_isolated_cmyk_group(&CmykGroup::new(x_object, &resources, context)); + + if !blended_in_cmyk { + interpret_form_contents(&resources, x_object, context, device); + } + + if own_layer { + device.pop_transparency_group(); + } + + context.pop_root_transform(); + context.restore_state(device); + + if has_oc { + context.ocg_state.end_marked_content(); + } + + context.end_nested_interpretation(); +} + +/// The contents of form `x_object`, in `context` as prepared for them (the form's `/Matrix` +/// applied, its root transform pushed), clipped to its `/BBox`. PdfCraft patch (45): shared by +/// [`draw_form_xobject`] and [`CmykGroup::interpret`]. +pub(crate) fn interpret_form_contents<'a>( + resources: &Resources<'a>, + x_object: &FormXObject<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); @@ -226,26 +264,13 @@ pub(crate) fn draw_form_xobject<'a, 'b>( }); interpret( - iter, - &Resources::for_form(x_object.resources.clone(), resources.clone()), + TypedIter::new(x_object.decoded.as_ref()), + resources, context, device, ); device.pop_clip_path(); - - if own_layer { - device.pop_transparency_group(); - } - - context.pop_root_transform(); - context.restore_state(device); - - if has_oc { - context.ocg_state.end_marked_content(); - } - - context.end_nested_interpretation(); } pub(crate) fn draw_image_xobject<'a, 'b>( diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 242c54ec2b..1b53a1417a 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -436,6 +436,11 @@ enum PaintSetup { Nothing, } +/// PdfCraft patch (21): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × +/// 4096 square). Each pixel costs about 20 bytes more than drawing the group as before (two +/// pass canvases, its tints, their RGB and the result); a larger group is drawn as before. +const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24; + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -464,6 +469,50 @@ pub(crate) struct Renderer { pub(crate) preview: bool, // TODO: Remove this once vello_cpu bug with non-transparent images is fixed image_transparency_stack: Vec, + /// PdfCraft patch (21): set while this renderer draws one ink pass of a group blending + /// in `DeviceCMYK` (see [`Renderer::draw_isolated_cmyk_group`]). + ink: InkState, +} + +/// PdfCraft patch (21): which inks a pass of a `DeviceCMYK` group draws, and whether the +/// group asked for something a pass can't draw. +/// +/// A pass paints the complement of ink tints as colour — no ink is white — so the RGB blend +/// functions compute ISO 32000-2 §11.3.4's subtractive blend, `1 − B(1 − cb, 1 − cs)`, and +/// complementing the pixels back gives the tints. The cyan, magenta and yellow pass paints +/// them as red, green and blue, which is also how §11.3.5.3 has the nonseparable modes treat +/// them. The black pass paints black ink as a grey, so a separable mode blends it like any +/// tint, and of the nonseparable modes Hue, Saturation and Color keep the backdrop's grey +/// while Luminosity takes the source's: §11.3.5.3's rule for the K component. +#[derive(Clone, Copy, Default)] +struct InkState { + pass: Option, + /// Something in the group has no ink tints (an RGB colour, a pattern, an image): the + /// group is drawn as before instead. + unsupported: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum InkPass { + /// Cyan, magenta and yellow as red, green and blue. + Cmy, + /// Black as grey. + K, +} + +impl InkState { + /// The colour a pass paints for `color`; transparent, with the group marked unsupported, + /// when the colour has no ink tints. + fn paint(&mut self, pass: InkPass, color: &Color) -> AlphaColor { + let Some(([c, m, y, k], alpha)) = color.ink_tints() else { + self.unsupported = true; + return AlphaColor::TRANSPARENT; + }; + match pass { + InkPass::Cmy => AlphaColor::new([1.0 - c, 1.0 - m, 1.0 - y, alpha]), + InkPass::K => AlphaColor::new([1.0 - k, 1.0 - k, 1.0 - k, alpha]), + } + } } #[derive(Clone, Copy)] @@ -502,6 +551,7 @@ impl Renderer { scaler: Scaler::new(ResamplingFunction::CatmullRom), preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), } } @@ -606,6 +656,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height()); let rgb_data = ImageData::Rgb(RgbData { @@ -1075,6 +1126,48 @@ impl Renderer { .fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64)); } + /// PdfCraft patch (21): one ink pass of `group` (see [`InkState`]) on a `width` × `height` + /// canvas whose corner is device point `offset`; `None` when the group holds something the + /// pass has no tints for. + fn ink_pass( + &self, + group: &hayro_interpret::CmykGroup<'_, '_>, + pass: InkPass, + offset: (f64, f64), + width: u16, + height: u16, + ) -> Option { + let mut renderer = Self { + ctx: RenderContext::new_with(width, height, derive_settings(self.ctx.render_settings())), + color_replacement: None, + in_annotations: self.in_annotations, + inside_pattern: self.inside_pattern, + soft_mask_cache: HashMap::default(), + outline_cache: self.outline_cache.clone(), + cur_mask: None, + in_type3_glyph: false, + scaler: Scaler::new(ResamplingFunction::CatmullRom), + preview: self.preview, + image_transparency_stack: Vec::new(), + ink: InkState { + pass: Some(pass), + unsupported: false, + }, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, + }; + group.interpret(&mut renderer, offset); + if renderer.ink.unsupported { + return None; + } + let mut pix = Pixmap::new(width, height); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + Some(pix) + } + /// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is /// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through /// untouched. @@ -1121,6 +1214,20 @@ impl Renderer { // covers its triangles alone and must not repeat outside them. let mut extend = peniko::Extend::Repeat; + // PdfCraft patch (21): an ink pass paints tints, and has none for a pattern. + if let Some(pass) = self.ink.pass { + let color = match paint { + Paint::Color(c) => self.ink.paint(pass, c), + Paint::Pattern(_) => { + self.ink.unsupported = true; + AlphaColor::TRANSPARENT + } + }; + self.ctx.set_paint_transform(Affine::IDENTITY); + self.ctx.set_paint(color); + return PaintSetup::Clip(None); + } + let paint: PaintType = match paint.clone() { Paint::Color(c) => { let c = c.to_rgba().to_rgba8(); @@ -1274,6 +1381,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let initial_transform = Affine::translate((BORDER, BORDER)) * Affine::scale_non_uniform(xs as f64, ys as f64) @@ -1336,6 +1444,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut initial_transform = Affine::scale_non_uniform(xs, ys) * Affine::translate((-bbox.x0, -bbox.y0)); @@ -1521,6 +1630,12 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { + // PdfCraft patch (21): an image's pixels are converted to RGB before they reach the + // device, so an ink pass has no tints for them; the group is drawn as before. + if self.ink.pass.is_some() { + self.ink.unsupported = true; + return; + } // PdfCraft patch (22): an erase pass paints nothing; an unmasked image's rectangle was // cleared by `knockout_shape`, and a masked image's shape is left alone. if self.erasing { @@ -1634,6 +1749,7 @@ impl<'a> Device<'a> for Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut sub_pix = Pixmap::new(width, height); sub_renderer.ctx.set_transform(transform); @@ -1697,6 +1813,77 @@ impl<'a> Device<'a> for Renderer { self.push_clip_path_inner(&clip_path.path, clip_path.fill); } + /// PdfCraft patch (21), with hayro-interpret patch (45): an isolated group that blends in + /// `DeviceCMYK` (ISO 32000-2 §11.3.4). Its contents are drawn twice on canvases of their own, + /// as cyan-magenta-yellow and as black (see [`InkState`]), and the tints those passes leave + /// are converted to RGB through the CMYK profile solid CMYK colour is drawn with. The + /// result goes into the layer the interpreter pushed for the group, which composites it + /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as + /// before) under reading colours, inside a pass (a nested group then blends in the pass's + /// own CMYK), and for contents a pass has no tints for. + fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool { + if self.color_replacement.is_some() || self.ink.pass.is_some() { + return false; + } + let canvas = Rect::new(0.0, 0.0, f64::from(self.ctx.width()), f64::from(self.ctx.height())); + let area = group.device_bbox().intersect(canvas); + if !(area.width() > 0.0 && area.height() > 0.0) { + // Nothing of the group is on the canvas (a NaN box lands here too). + return true; + } + let (x0, y0) = (area.x0.floor(), area.y0.floor()); + // Within the canvas, so the sides fit its u16 ones. + let width = (area.x1.ceil() - x0) as u16; + let height = (area.y1.ceil() - y0) as u16; + if width == 0 || height == 0 { + return true; + } + if u64::from(width) * u64::from(height) > MAX_CMYK_GROUP_PIXELS { + return false; + } + + let Some(cmy) = self.ink_pass(group, InkPass::Cmy, (x0, y0), width, height) else { + return false; + }; + let Some(k) = self.ink_pass(group, InkPass::K, (x0, y0), width, height) else { + return false; + }; + + // A tint is a pass's premultiplied colour un-premultiplied and complemented. + let ink = |premul: u8, alpha: u8| { + if alpha == 0 { + return 0; + } + let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); + 255 - straight.min(255) as u8 + }; + let pixels = usize::from(width) * usize::from(height); + let mut tints = Vec::with_capacity(pixels * 4); + for (p, q) in cmy.data().iter().zip(k.data()) { + // Both passes draw the same shapes at the same opacities, so they cover the same + // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. + tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); + } + let mut rgb = vec![0u8; pixels * 3]; + if hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb).is_none() { + return false; + } + + let mut result = Pixmap::new(width, height); + for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { + let a = u32::from(coverage.a); + let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; + *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + } + + // The group's own soft mask and blend mode are on its layer; what the contents set + // must not apply to the finished pixels a second time. + self.set_soft_mask(None); + self.set_blend_mode(BlendMode::Normal); + self.draw_pixmap(Arc::new(result), ImageQuality::Low, Affine::translate((x0, y0))); + true + } + fn push_transparency_group( &mut self, opacity: f32, @@ -2026,6 +2213,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei scaler: Scaler::new(ResamplingFunction::CatmullRom), preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let bg_color = mask.background_color().to_rgba(); From 7f9530daf96ef4b638e2962df4b8c565aa35a91a Mon Sep 17 00:00:00 2001 From: jpeskov <101976824+jpeskov@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:53:30 +0530 Subject: [PATCH 68/75] fix(render): an ICCBased CMYK colour is not taken as process tints; fix a patch number Review of #3777. `Color::ink_tints` no longer gives tints for a four-component ICCBased colour. Such a colour keeps its own profile, so a group that holds one is drawn as before. vendor/README.md: the hayro patch (21) entry refers to hayro-interpret patch (45), not (44). --- vendor/README.md | 4 ++-- vendor/hayro-interpret/src/color.rs | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index 73140ab8d7..0b11853c62 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, four-component ICC unconverted, DeviceGray as black alone, §10.4.2.2) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell) | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk` | -| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (44)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk` | +| 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell) | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk` | +| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (45)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk` | | 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/color.rs b/vendor/hayro-interpret/src/color.rs index 348dd51758..c7a6514d5e 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1403,9 +1403,9 @@ impl Color { /// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4). /// /// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray` - /// (§10.4.2.2). Four-component ICC colour is taken as the tints it names, unconverted. - /// `None` for every other space: such a colour has no tints without a colour conversion - /// this crate does not make, and the caller draws it as before. + /// (§10.4.2.2). `None` for every other space, ICCBased CMYK included: such a colour has no + /// tints without a colour conversion this crate does not make (an ICCBased colour keeps + /// its own profile), and the caller draws it as before. pub fn ink_tints(&self) -> Option<([f32; 4], f32)> { let c = |i: usize| { self.components @@ -1416,9 +1416,6 @@ impl Color { }; let tints = match self.color_space.0.as_ref() { ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)], - ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => { - [c(0), c(1), c(2), c(3)] - } ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)], _ => return None, }; From f4fe2d9210e8abd64f2a20734b46a8453052aff1 Mon Sep 17 00:00:00 2001 From: jpeskov <101976824+jpeskov@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:35:09 +0530 Subject: [PATCH 69/75] fix(render): blend large CMYK groups in every tile; decline nested isolated other-space groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #3777 (test report by mtlcl410). - A group wider than about 512 device pixels lost its blend mode in the tiles that a paint covers completely: on the bare pass canvas, vello_cpu fills such a tile without the blend mode. At 300 dpi, magenta then black with Difference drew black in the middle of the group, not green. Each ink pass now draws inside a layer of its own. - A nested isolated group that names another blending space (for example /CS /DeviceRGB) blends in that space (ISO 32000-2 §11.3.4). The interpreter now tells the device about such a group (Device::group_blends_outside_device_cmyk), and a pass that meets one declines, so the enclosing group is drawn as before. A non-isolated group's /CS is ignored (ISO 32000-2 §11.6.6, Table 145, CS: it takes its parent's space), so it stays in the enclosing group's CMYK. Test: raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces. Co-Authored-By: Claude Opus 5.5 --- .../src/raster/tests/isolated_cmyk_groups.rs | 73 +++++++++++++++++++ vendor/README.md | 4 +- vendor/hayro-interpret/src/cmyk_group.rs | 5 ++ vendor/hayro-interpret/src/device.rs | 4 + vendor/hayro-interpret/src/x_object.rs | 14 +++- vendor/hayro/src/renderer.rs | 12 +++ 6 files changed, 109 insertions(+), 3 deletions(-) diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index 0d6294e379..5384f0759e 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -95,3 +95,76 @@ endstream endobj let rgb_group = render("/CS /DeviceRGB /I true", &rgb); assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before"); } + +/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost +/// its blend mode in the tiles a paint covered completely, which drew black over magenta at +/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested +/// isolated group that names another blending space (here DeviceRGB) blends in that space +/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated +/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145). +#[test] +fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() { + let render = |inner_cs: Option<&str>, scale: f32| { + // `inner_cs`: the nested group's /CS and /I entries. + let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f"; + let (outer_body, extra) = match inner_cs { + None => (group.to_string(), String::new()), + Some(cs) => ( + "/In Do".to_string(), + format!( + "6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n", + group.len() + ), + ), + }; + let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream +{outer_body} +endstream endobj +{extra}trailer << /Root 1 0 R >> +%%EOF", + page.len(), + outer_body.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p + }; + let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| { + let (x, y) = ((x * scale) as u32, (y * scale) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec() + }; + let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + + // 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely. + for scale in [1.0, 4.0] { + let p = render(None, scale); + let strip = px(&p, 110.0, 245.0, scale); + for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] { + let group = px(&p, x, y, scale); + assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}"); + } + } + + // A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly + // as when the enclosing group is not offered to the CMYK path at all. + let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0); + let green = px(&nested_rgb, 110.0, 245.0, 1.0); + assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK"); + // A nested DeviceCMYK group stays in the CMYK path. + let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0); + assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3); + // A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the + // enclosing group's CMYK, even when it names DeviceRGB. + let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0); + assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK"); +} diff --git a/vendor/README.md b/vendor/README.md index 0b11853c62..2bfdba1e26 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell) | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk` | -| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (45)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk` | +| 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell); `Device::group_blends_outside_device_cmyk` tells the device that a nested isolated group names another blending space, so an enclosing CMYK group can decline | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk`, `raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces` | +| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (45)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`); each pass draws inside a layer (on the bare canvas, vello_cpu filled tiles a paint covers completely without the blend mode, so the middle of a group over about 512 px lost its blend), and a pass that meets a nested isolated group naming another blending space declines (a non-isolated group's `/CS` is ignored, ISO 32000-2 Table 145, so it stays in CMYK) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk`, `raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces` | | 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/cmyk_group.rs b/vendor/hayro-interpret/src/cmyk_group.rs index 1135171c9f..0b906bf13d 100644 --- a/vendor/hayro-interpret/src/cmyk_group.rs +++ b/vendor/hayro-interpret/src/cmyk_group.rs @@ -93,3 +93,8 @@ impl<'a, 'b> CmykGroup<'a, 'b> { pub(crate) fn blends_in_device_cmyk(form: &Dict<'_>) -> bool { form.get::>(GROUP).and_then(|g| g.get::>(CS)).is_some_and(|cs| matches!(cs.deref(), b"DeviceCMYK" | b"CMYK")) } + +/// Whether `form`'s transparency group names a blending colour space of its own (`/CS`). +pub(crate) fn names_a_blending_space(form: &Dict<'_>) -> bool { + form.get::>(GROUP).is_some_and(|g| g.contains_key(CS)) +} diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 4aea7ad2c0..68fdd6af75 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -115,6 +115,10 @@ pub trait Device<'a> { fn draw_isolated_cmyk_group(&mut self, _group: &CmykGroup<'a, '_>) -> bool { false } + /// PdfCraft patch (45): an isolated transparency group that names a blending colour space + /// other than `DeviceCMYK` is about to be drawn. A device drawing an enclosing group in CMYK + /// can no longer draw that group right, since this one blends in its own space. + fn group_blends_outside_device_cmyk(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index df192ee973..f33ae9b499 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,5 +1,5 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::cmyk_group::{CmykGroup, blends_in_device_cmyk}; +use crate::cmyk_group::{CmykGroup, blends_in_device_cmyk, names_a_blending_space}; use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb}; use crate::context::Context; use crate::device::Device; @@ -215,6 +215,18 @@ pub(crate) fn draw_form_xobject<'a, 'b>( // its own, so a device able to blend in CMYK computes its interior apart and composites the // result through the layer pushed above (see `cmyk_group`). Knockout groups are left out: // their elements composite with the group's initial backdrop, not with each other. + // An isolated group that names another blending space blends in that space (§11.3.4); a + // device drawing an enclosing group in CMYK is told, so it can decline that group. A + // non-isolated group's /CS is ignored: it blends in its parent's space (ISO 32000-2 + // Table 145, CS), so it stays in the enclosing group's CMYK. + if x_object.is_transparency_group + && x_object.is_isolated + && names_a_blending_space(&x_object.dict) + && !blends_in_device_cmyk(&x_object.dict) + { + device.group_blends_outside_device_cmyk(); + } + let blended_in_cmyk = own_layer && x_object.is_isolated && !x_object.is_knockout diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 1b53a1417a..df5b737aa7 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1157,7 +1157,12 @@ impl Renderer { erasing: false, erase_groups: 0, }; + // The pass draws inside a layer of its own. On the bare canvas, vello_cpu fills a tile + // that a paint covers completely without the blend mode, so the middle of a group wider + // than about two tiles lost its blend (#3777). + renderer.ctx.push_layer(None, None, None, None, None); group.interpret(&mut renderer, offset); + renderer.ctx.pop_layer(); if renderer.ink.unsupported { return None; } @@ -1821,6 +1826,13 @@ impl<'a> Device<'a> for Renderer { /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as /// before) under reading colours, inside a pass (a nested group then blends in the pass's /// own CMYK), and for contents a pass has no tints for. + /// PdfCraft patch (21): a nested group blending in another space can't be drawn in a pass. + fn group_blends_outside_device_cmyk(&mut self) { + if self.ink.pass.is_some() { + self.ink.unsupported = true; + } + } + fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool { if self.color_replacement.is_some() || self.ink.pass.is_some() { return false; From d1f5658c89899cec273e3f56738e78629a53940f Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 17:21:06 -0400 Subject: [PATCH 70/75] chore(vendor): renumber the CMYK-group patches after rebasing onto main main has taken hayro-interpret patches up to (66) and hayro up to (25) since #3777 was opened, and open PRs claim hayro-interpret (67)-(69) and hayro (26). The isolated DeviceCMYK group patches become hayro-interpret (70) and hayro (27). vendor/README.md names the tests by their new path, raster::tests::isolated_cmyk_groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/src/raster/tests.rs | 4 ++-- .../src/raster/tests/isolated_cmyk_groups.rs | 2 +- vendor/README.md | 4 ++-- vendor/hayro-interpret/src/cmyk_group.rs | 4 ++-- vendor/hayro-interpret/src/color.rs | 4 ++-- vendor/hayro-interpret/src/device.rs | 4 ++-- vendor/hayro-interpret/src/x_object.rs | 4 ++-- vendor/hayro/src/renderer.rs | 16 ++++++++-------- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 5b4d1036d5..a4e4404e6b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7004,8 +7004,8 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; -// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 45, hayro -// patch 21). +// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro +// patch 27). mod isolated_cmyk_groups; /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index 5384f0759e..2cea9d5854 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -11,7 +11,7 @@ use std::sync::Arc; /// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop /// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so /// magenta under black with Difference came out magenta instead of green. Vendored -/// hayro-interpret patch (45) and hayro patch (21): an isolated group (§11.4.7: its own +/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own /// transparent backdrop) blending in DeviceCMYK is blended in CMYK. /// /// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated diff --git a/vendor/README.md b/vendor/README.md index 2bfdba1e26..5e70a0ab27 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; (45) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell); `Device::group_blends_outside_device_cmyk` tells the device that a nested isolated group names another blending space, so an enclosing CMYK group can decline | (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); (45) `raster::tests::isolated_groups_blend_in_device_cmyk`, `raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces` | -| 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); (21) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (45)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`); each pass draws inside a layer (on the bare canvas, vello_cpu filled tiles a paint covers completely without the blend mode, so the middle of a group over about 512 px lost its blend), and a pass that meets a nested isolated group naming another blending space declines (a non-isolated group's `/CS` is ignored, ISO 32000-2 Table 145, so it stays in CMYK) | (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`); (21) `raster::tests::isolated_groups_blend_in_device_cmyk`, `raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces` | +| 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; (70) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell); `Device::group_blends_outside_device_cmyk` tells the device that a nested isolated group names another blending space, so an enclosing CMYK group can decline | (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); (70) `raster::tests::isolated_cmyk_groups::{isolated_groups_blend_in_device_cmyk, isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces}` | +| 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); (27) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (70)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`); each pass draws inside a layer (on the bare canvas, vello_cpu filled tiles a paint covers completely without the blend mode, so the middle of a group over about 512 px lost its blend), and a pass that meets a nested isolated group naming another blending space declines (a non-isolated group's `/CS` is ignored, ISO 32000-2 Table 145, so it stays in CMYK) | (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`); (27) `raster::tests::isolated_cmyk_groups::{isolated_groups_blend_in_device_cmyk, isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces}` | | 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/cmyk_group.rs b/vendor/hayro-interpret/src/cmyk_group.rs index 0b906bf13d..f791895798 100644 --- a/vendor/hayro-interpret/src/cmyk_group.rs +++ b/vendor/hayro-interpret/src/cmyk_group.rs @@ -1,4 +1,4 @@ -//! PdfCraft patch (45): an isolated transparency group that blends in `DeviceCMYK`. +//! PdfCraft patch (70): an isolated transparency group that blends in `DeviceCMYK`. //! //! ISO 32000-2 §11.3.4: a group's elements blend in the group's colour space, and the //! components of a subtractive space are complemented before the blend function and @@ -26,7 +26,7 @@ use kurbo::{Affine, Rect, Shape}; use std::ops::Deref; /// An isolated, non-knockout transparency group whose blending colour space is `DeviceCMYK`, -/// captured where it is painted (PdfCraft patch (45); see the module documentation). +/// captured where it is painted (PdfCraft patch (70); see the module documentation). pub struct CmykGroup<'a, 'b> { form: &'b FormXObject<'a>, resources: &'b Resources<'a>, diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index c7a6514d5e..29a74a6de8 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1399,7 +1399,7 @@ impl Color { .to_rgba(&self.components, self.opacity, false) } - /// PdfCraft patch (45): the colour as process ink tints `[C, M, Y, K]` (0 = no ink) and + /// PdfCraft patch (70): the colour as process ink tints `[C, M, Y, K]` (0 = no ink) and /// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4). /// /// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray` @@ -1433,7 +1433,7 @@ impl Color { } } -/// PdfCraft patch (45): `input`, 8-bit `DeviceCMYK` pixels (four bytes each), converted to +/// PdfCraft patch (70): `input`, 8-bit `DeviceCMYK` pixels (four bytes each), converted to /// 8-bit sRGB in `output` (three bytes each) through the CMYK profile pages are drawn with, /// so a colour blended in CMYK lands where the same colour painted directly does. pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> { diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 68fdd6af75..5eccac7665 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -108,14 +108,14 @@ 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 (45): draw the contents of an isolated transparency group that blends + /// PdfCraft patch (70): draw the contents of an isolated transparency group that blends /// in `DeviceCMYK` (ISO 32000-2 §11.3.4), already inside the layer it is composited /// through. Return `true` when the device drew them; on `false` (the default) the /// interpreter draws the contents into the device as for any other group. fn draw_isolated_cmyk_group(&mut self, _group: &CmykGroup<'a, '_>) -> bool { false } - /// PdfCraft patch (45): an isolated transparency group that names a blending colour space + /// PdfCraft patch (70): an isolated transparency group that names a blending colour space /// other than `DeviceCMYK` is about to be drawn. A device drawing an enclosing group in CMYK /// can no longer draw that group right, since this one blends in its own space. fn group_blends_outside_device_cmyk(&mut self) {} diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index f33ae9b499..9feb6dae01 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -211,7 +211,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( let resources = Resources::for_form(x_object.resources.clone(), resources.clone()); - // PdfCraft patch (45): an isolated group blending in DeviceCMYK starts from a backdrop of + // PdfCraft patch (70): an isolated group blending in DeviceCMYK starts from a backdrop of // its own, so a device able to blend in CMYK computes its interior apart and composites the // result through the layer pushed above (see `cmyk_group`). Knockout groups are left out: // their elements composite with the group's initial backdrop, not with each other. @@ -252,7 +252,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( } /// The contents of form `x_object`, in `context` as prepared for them (the form's `/Matrix` -/// applied, its root transform pushed), clipped to its `/BBox`. PdfCraft patch (45): shared by +/// applied, its root transform pushed), clipped to its `/BBox`. PdfCraft patch (70): shared by /// [`draw_form_xobject`] and [`CmykGroup::interpret`]. pub(crate) fn interpret_form_contents<'a>( resources: &Resources<'a>, diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index df5b737aa7..7ea6fac827 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -436,7 +436,7 @@ enum PaintSetup { Nothing, } -/// PdfCraft patch (21): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × +/// PdfCraft patch (27): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × /// 4096 square). Each pixel costs about 20 bytes more than drawing the group as before (two /// pass canvases, its tints, their RGB and the result); a larger group is drawn as before. const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24; @@ -469,12 +469,12 @@ pub(crate) struct Renderer { pub(crate) preview: bool, // TODO: Remove this once vello_cpu bug with non-transparent images is fixed image_transparency_stack: Vec, - /// PdfCraft patch (21): set while this renderer draws one ink pass of a group blending + /// PdfCraft patch (27): set while this renderer draws one ink pass of a group blending /// in `DeviceCMYK` (see [`Renderer::draw_isolated_cmyk_group`]). ink: InkState, } -/// PdfCraft patch (21): which inks a pass of a `DeviceCMYK` group draws, and whether the +/// PdfCraft patch (27): which inks a pass of a `DeviceCMYK` group draws, and whether the /// group asked for something a pass can't draw. /// /// A pass paints the complement of ink tints as colour — no ink is white — so the RGB blend @@ -1126,7 +1126,7 @@ impl Renderer { .fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64)); } - /// PdfCraft patch (21): one ink pass of `group` (see [`InkState`]) on a `width` × `height` + /// PdfCraft patch (27): one ink pass of `group` (see [`InkState`]) on a `width` × `height` /// canvas whose corner is device point `offset`; `None` when the group holds something the /// pass has no tints for. fn ink_pass( @@ -1219,7 +1219,7 @@ impl Renderer { // covers its triangles alone and must not repeat outside them. let mut extend = peniko::Extend::Repeat; - // PdfCraft patch (21): an ink pass paints tints, and has none for a pattern. + // PdfCraft patch (27): an ink pass paints tints, and has none for a pattern. if let Some(pass) = self.ink.pass { let color = match paint { Paint::Color(c) => self.ink.paint(pass, c), @@ -1635,7 +1635,7 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { - // PdfCraft patch (21): an image's pixels are converted to RGB before they reach the + // PdfCraft patch (27): an image's pixels are converted to RGB before they reach the // device, so an ink pass has no tints for them; the group is drawn as before. if self.ink.pass.is_some() { self.ink.unsupported = true; @@ -1818,7 +1818,7 @@ impl<'a> Device<'a> for Renderer { self.push_clip_path_inner(&clip_path.path, clip_path.fill); } - /// PdfCraft patch (21), with hayro-interpret patch (45): an isolated group that blends in + /// PdfCraft patch (27), with hayro-interpret patch (70): an isolated group that blends in /// `DeviceCMYK` (ISO 32000-2 §11.3.4). Its contents are drawn twice on canvases of their own, /// as cyan-magenta-yellow and as black (see [`InkState`]), and the tints those passes leave /// are converted to RGB through the CMYK profile solid CMYK colour is drawn with. The @@ -1826,7 +1826,7 @@ impl<'a> Device<'a> for Renderer { /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as /// before) under reading colours, inside a pass (a nested group then blends in the pass's /// own CMYK), and for contents a pass has no tints for. - /// PdfCraft patch (21): a nested group blending in another space can't be drawn in a pass. + /// PdfCraft patch (27): a nested group blending in another space can't be drawn in a pass. fn group_blends_outside_device_cmyk(&mut self) { if self.ink.pass.is_some() { self.ink.unsupported = true; From 41277846a3fceac5d6f3c71712956e399078972d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 17:28:43 -0400 Subject: [PATCH 71/75] fix(vendor): keep draw_isolated_cmyk_group's doc comment on that function The nested-group hook was inserted between the comment and the function it documents, so rustdoc gave it to group_blends_outside_device_cmyk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- vendor/hayro/src/renderer.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 7ea6fac827..ba4c8e3c34 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1818,6 +1818,13 @@ impl<'a> Device<'a> for Renderer { self.push_clip_path_inner(&clip_path.path, clip_path.fill); } + /// PdfCraft patch (27): a nested group blending in another space can't be drawn in a pass. + fn group_blends_outside_device_cmyk(&mut self) { + if self.ink.pass.is_some() { + self.ink.unsupported = true; + } + } + /// PdfCraft patch (27), with hayro-interpret patch (70): an isolated group that blends in /// `DeviceCMYK` (ISO 32000-2 §11.3.4). Its contents are drawn twice on canvases of their own, /// as cyan-magenta-yellow and as black (see [`InkState`]), and the tints those passes leave @@ -1826,13 +1833,6 @@ impl<'a> Device<'a> for Renderer { /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as /// before) under reading colours, inside a pass (a nested group then blends in the pass's /// own CMYK), and for contents a pass has no tints for. - /// PdfCraft patch (27): a nested group blending in another space can't be drawn in a pass. - fn group_blends_outside_device_cmyk(&mut self) { - if self.ink.pass.is_some() { - self.ink.unsupported = true; - } - } - fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool { if self.color_replacement.is_some() || self.ink.pass.is_some() { return false; From 6fcfb3f98d9fbcf18e39b524407024b238d5749a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 17:46:00 -0400 Subject: [PATCH 72/75] fix(render): an isolated CMYK group inside a knockout element is erased, not painted main's knockout patch (hayro (22)) draws each element of a knockout group once in erase mode, to clear what the group drew under the element's shape. An isolated DeviceCMYK group nested in such an element still took the CMYK path in that pass, and drew its finished pixels with Normal blending over what should have been cleared. draw_isolated_cmyk_group now declines while erasing, so the group's contents are interpreted in erase mode as before. The new test fails without the fix (the centre shows red through the magenta: [240, 63, 168] against [245, 127, 197] for the DeviceRGB control). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- .../src/raster/tests/isolated_cmyk_groups.rs | 46 +++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 5 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index 2cea9d5854..44df1a0e45 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -168,3 +168,49 @@ endstream endobj let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0); assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK"); } + +/// An element of a knockout group is first drawn in erase mode, which clears what the group +/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must +/// clear too: when the erase pass drew it through the CMYK path, it painted over the red +/// beneath instead of clearing it. The page must match the same element with its nested group +/// blending in `DeviceRGB`, which never takes the CMYK path. +#[test] +fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() { + let render = |inner_cs: &str| { + let knockout = "1 0 0 rg 20 20 160 160 re f /El Do"; + let element = "/Cm Do"; + let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj +4 0 obj << /Length 7 >> stream +/Ko Do +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream +{knockout} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream +{element} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream +{inner} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + knockout.len(), + element.len(), + inner.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error); + // The centre of the inner square, (100, 100), is also the centre of the page. + p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec() + }; + let rgb = render("/DeviceRGB"); + let cmyk = render("/DeviceCMYK"); + assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}"); + assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}"); +} diff --git a/vendor/README.md b/vendor/README.md index 5e70a0ab27..35a080c6be 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; (70) `CmykGroup` and `Device::draw_isolated_cmyk_group`: an isolated, non-knockout transparency group whose `/CS` is `DeviceCMYK` is offered to the device to blend in CMYK (ISO 32000-2 §11.3.4: a subtractive space's components are complemented before the blend function and back after it), and `interpret_form_contents` draws the group's contents for it again on a canvas of the device's own; a device that declines (the default) gets the contents as before. `Color::ink_tints` gives a colour's process tints (DeviceCMYK, and DeviceGray as black alone, §10.4.2.2; an ICCBased colour gives none) and `color::cmyk_to_srgb8` converts tints through the CMYK profile pages are drawn with (a print test file: magenta under black with Difference blended in RGB stayed magenta instead of turning green, an X in nearly every cell); `Device::group_blends_outside_device_cmyk` tells the device that a nested isolated group names another blending space, so an enclosing CMYK group can decline | (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); (70) `raster::tests::isolated_cmyk_groups::{isolated_groups_blend_in_device_cmyk, isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces}` | -| 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); (27) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (70)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`); each pass draws inside a layer (on the bare canvas, vello_cpu filled tiles a paint covers completely without the blend mode, so the middle of a group over about 512 px lost its blend), and a pass that meets a nested isolated group naming another blending space declines (a non-isolated group's `/CS` is ignored, ISO 32000-2 Table 145, so it stays in CMYK) | (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`); (27) `raster::tests::isolated_cmyk_groups::{isolated_groups_blend_in_device_cmyk, isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces}` | +| 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); (27) `Renderer::draw_isolated_cmyk_group` (with hayro-interpret (70)): the group is drawn twice on canvases covering its box, painting the complement of its cyan, magenta and yellow tints as red, green and blue and the complement of its black as grey (`InkState`), so the RGB blend functions compute the subtractive blend and §11.3.5.3's rule for K (Hue, Saturation and Color keep the backdrop's, Luminosity takes the source's); the tints left are converted to RGB and composited through the group's layer as before. Declined, and drawn as before, under reading colours, for contents with no tints (RGB or other colour spaces, patterns, images) and for groups over 2^24 device pixels (`MAX_CMYK_GROUP_PIXELS`); each pass draws inside a layer (on the bare canvas, vello_cpu filled tiles a paint covers completely without the blend mode, so the middle of a group over about 512 px lost its blend), a group met while erasing for a knockout group (patch (22)) is erased like any other, and a pass that meets a nested isolated group naming another blending space declines (a non-isolated group's `/CS` is ignored, ISO 32000-2 Table 145, so it stays in CMYK) | (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`); (27) `raster::tests::isolated_cmyk_groups::{isolated_groups_blend_in_device_cmyk, isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces, an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other}` | | 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 ba4c8e3c34..a37df005a0 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1832,9 +1832,10 @@ impl<'a> Device<'a> for Renderer { /// result goes into the layer the interpreter pushed for the group, which composites it /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as /// before) under reading colours, inside a pass (a nested group then blends in the pass's - /// own CMYK), and for contents a pass has no tints for. + /// own CMYK), while erasing for a knockout group (the contents must clear, not paint), and + /// for contents a pass has no tints for. fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool { - if self.color_replacement.is_some() || self.ink.pass.is_some() { + if self.color_replacement.is_some() || self.ink.pass.is_some() || self.erasing { return false; } let canvas = Rect::new(0.0, 0.0, f64::from(self.ctx.width()), f64::from(self.ctx.height())); From 367f7d5432a02fe83849cb68ccb83be5d663e4e3 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 28 Oct 2026 20:40:16 -0400 Subject: [PATCH 73/75] fix(render): a pattern before an isolated CMYK group no longer moves it From the review of #5228. The finished CMYK group was drawn through whatever paint transform a preceding shading pattern left on the context, so its cyan half showed magenta. Hayro patch (27) now resets the paint transform before drawing the group's pixels. Two regression tests, both reported in that review: - a pattern painted before the group (fails without the reset); - the Color blend mode over an empty CMYK group, swept over all 256 K levels (failed on nine levels before #5199's vello_cpu fix was merged in; passes with it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- .../src/raster/tests/isolated_cmyk_groups.rs | 66 +++++++++++++++++++ vendor/hayro/src/renderer.rs | 2 + 2 files changed, 68 insertions(+) diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index 44df1a0e45..c9cc9aa492 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -214,3 +214,69 @@ trailer << /Root 1 0 R >> assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}"); assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}"); } + +/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated +/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`). +fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream +{group} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + media[0], + media[1], + page.len(), + bbox[0], + bbox[1], + bbox[2], + bbox[3], + group.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec() +} + +/// From the review of #5228: a shading pattern painted before the group left its pattern +/// transform on the context, and the finished group was drawn through it, so the cyan half +/// of the group showed magenta. The group must look the same whether or not a pattern was +/// painted first. +#[test] +fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() { + let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f"; + let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50)); + let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do"); + let alone = render("/G Do"); + assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}"); + assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}"); +} + +/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop +/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass +/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to +/// near black. Every 8-bit K level is checked against a plain fill of the same ink. +#[test] +fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() { + let mut wrong = Vec::new(); + for level in 0..=255u8 { + let k = format!("0 0 0 {} k", f32::from(level) / 255.0); + let page = format!("{k} 0 0 20 20 re f /G Do"); + let group = format!("/B gs {k} 20 0 20 20 re f"); + let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10)); + let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10)); + if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) { + wrong.push((level, plain, blended)); + } + } + assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}"); +} diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index a37df005a0..635cabf349 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1893,6 +1893,8 @@ impl<'a> Device<'a> for Renderer { // must not apply to the finished pixels a second time. self.set_soft_mask(None); self.set_blend_mode(BlendMode::Normal); + // A pattern painted earlier leaves its paint transform on the context. + self.ctx.set_paint_transform(Affine::IDENTITY); self.draw_pixmap(Arc::new(result), ImageQuality::Low, Affine::translate((x0, y0))); true } From 97c062a14b01714c9387744c5a41b0d8cb11cf10 Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 29 Oct 2026 02:40:41 +0100 Subject: [PATCH 74/75] Complete additive integration fields and test boundaries --- crates/automation/tests/it/main.rs | 2 +- crates/forms/src/tests.rs | 2 ++ crates/render/tests/it/main.rs | 2 +- vendor/hayro/src/renderer.rs | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 3e81bbc450..b48b23bf0a 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,5 +8,5 @@ mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; -mod redact_text_after_q; mod redact_after_stray_q_form; +mod redact_text_after_q; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index e92005bfce..ac414af2ae 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5125,6 +5125,8 @@ fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() { set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group"); assert_eq!(fields(&doc).len(), 3); +} + /// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent. fn selection_indices(doc: &Document, name: &str) -> Option> { let f = field(&fields(doc), name).clone(); diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index ff2985e128..5323279ca1 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -6,8 +6,8 @@ mod jbig2_invariants; mod knockout_backdrop; mod luminosity_group_space; mod named_form_properties; -mod open_type_cff_simple_font; mod nonseparable_gray; +mod open_type_cff_simple_font; mod search; mod separation_all; mod stray_q_in_a_form; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 1911c66190..ac103fa197 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1187,6 +1187,7 @@ impl Renderer { knockout: Vec::new(), erasing: false, erase_groups: 0, + knockout_passes: Vec::new(), }; // The pass draws inside a layer of its own. On the bare canvas, vello_cpu fills a tile // that a paint covers completely without the blend mode, so the middle of a group wider From d99538145c4d92498c787f023089a348a8921bf6 Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 29 Oct 2026 02:43:29 +0100 Subject: [PATCH 75/75] Clarify integrated vendor regression coverage --- vendor/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index 8fe4894d9a..84b2546bf3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -140,5 +140,5 @@ is built in memory. | #4898 | Preserve non-isolated knockout-group backdrops across blend-mode passes, masks, and shading. | `knockout_backdrop` | | #4913 | Discard invalid stroke outlines without losing valid path segments. | `stroke_degenerate_cubic` | | #5178 | Read OpenType-wrapped CFF programs for embedded simple fonts. | `open_type_cff_simple_font` | -| #5199 | Preserve gray under nonseparable Color blending in vello_common. | `nonseparable_gray`, vendored blend tests | -| #5228 | Blend supported isolated DeviceCMYK groups in their ink space. | `isolated_cmyk_group` | +| #5199 | Preserve gray under nonseparable Color blending in vello_cpu. | `nonseparable_gray` | +| #5228 | Blend supported isolated DeviceCMYK groups in their ink space. | `raster::tests::isolated_cmyk_groups` |