From 3c366d5e16643040aaeddc08d3a6cc2f2b095fb6 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:50:10 -0600 Subject: [PATCH] Render: radial luminosity fades have regression coverage --- ROADMAP.md | 2 + crates/render/src/raster.rs | 2 + .../src/raster/radial_softmask_tests.rs | 69 +++++++++++++++++++ docs/roadmap.md | 2 +- 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/raster/radial_softmask_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..21fb23aa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 45641178d..97dd07795 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -16,6 +16,8 @@ use std::sync::mpsc::SyncSender; #[cfg(not(target_arch = "wasm32"))] use std::sync::mpsc::{Receiver, sync_channel}; +#[cfg(test)] +mod radial_softmask_tests; mod work_queue; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; diff --git a/crates/render/src/raster/radial_softmask_tests.rs b/crates/render/src/raster/radial_softmask_tests.rs new file mode 100644 index 000000000..674dce5d6 --- /dev/null +++ b/crates/render/src/raster/radial_softmask_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +fn masked_shading(shading: &str, extra: &str, ink: &str) -> Vec { + let content = format!("20.25 20.25 279.5 279.5 re W n /M gs {ink} 0 0 320 320 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 320 320] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [160 160 0 160 160 180] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0.5 0] /N 1 >> >> >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /ExtGState /SMask << /S /Luminosity /G 6 0 R >> >> endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 320 320] /Group << /S /Transparency /CS /DeviceCMYK >> /Resources << /Shading << /Sh0 {shading} >> >> /Length 7 >> stream +/Sh0 sh +endstream endobj +{extra} +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + pdf.into_bytes() +} + +fn render_mask(bytes: Vec, request: RenderRequest) -> RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(request); + assert!(page.error.is_none(), "{:?}", page.error); + page +} + +#[test] +fn radial_luminosity_mask_fades_when_painted_by_one_shading() { + let bytes = masked_shading(RADIAL_MASK, "", "/Pattern cs /P scn"); + + let page = render_mask(bytes, RenderRequest { scale: 1.0, ..Default::default() }); + + for (x, blue) in [(160_u32, 0..=5), (250, 124..=132), (299, 200..=250), (300, 255..=255)] { + let offset = usize::try_from((160 * page.width + x) * 4).unwrap(); + let pixel = &page.rgba[offset..offset + 4]; + assert!(blue.contains(&pixel[2]), "centre/midpoint/antialiased clip/outside, x {x}: {pixel:?}, blue {blue:?}"); + } +} + +const RADIAL_MASK: &str = "<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [160 160 0 160 160 180] /Extend [true true] /AntiAlias false /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>"; + +#[test] +fn radial_luminosity_mask_matches_per_pixel_shading_when_scaled_or_tiled() { + let code = "{ 160 sub dup mul exch 160 sub dup mul add sqrt 180 div 1 exch sub }"; + let extra = format!("7 0 obj << /FunctionType 4 /Domain [0 320 0 320] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj", code.len()); + for ink in ["1 0 0 rg", "/Pattern cs /P scn"] { + let radial = masked_shading(RADIAL_MASK, "", ink); + let sampled = masked_shading("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 320 0 320] /Function 7 0 R >>", &extra, ink); + for scale in [0.5, 1.0, 2.0] { + for preview in [false, true] { + for tile in [None, Some(Tile { x: 64, y: 64, w: 160, h: 160 })] { + let request = RenderRequest { scale, preview, tile, ..Default::default() }; + + let native = render_mask(radial.clone(), request); + let reference = render_mask(sampled.clone(), request); + + assert_eq!((native.width, native.height), (reference.width, reference.height)); + let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(worst.is_some_and(|d| d <= 4), "{ink}, {request:?}: radial luminosity mask differs from sampled reference by {worst:?}"); + } + } + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9..e122dadbc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | -| 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. | | 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. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |