From fcc1d9ead0f4c850a8c71637a90a92aa1f871004 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:59:51 +0200 Subject: [PATCH] M2: adapt Lab Bradford white points in LMS (#1417) Follow up #1237 by deriving Bradford scaling ratios from the source and D65 white points in LMS. Preserve CalRGB and the merged Indexed/Range handling. Add independent neutral/chromatic, identity, asymmetric Range, and degenerate-cone render regressions. Co-authored-by: Codex --- ROADMAP.md | 1 + crates/render/src/lab_bradford_tests.rs | 113 ++++++++++++++++++++++++ crates/render/src/lib.rs | 3 + docs/roadmap.md | 2 +- vendor/README.md | 7 ++ vendor/hayro-interpret/src/color.rs | 23 ++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/lab_bradford_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1ff5c9af7..78ad856a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. - **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. - **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. diff --git a/crates/render/src/lab_bradford_tests.rs b/crates/render/src/lab_bradford_tests.rs new file mode 100644 index 000000000..7ab1e7ed3 --- /dev/null +++ b/crates/render/src/lab_bradford_tests.rs @@ -0,0 +1,113 @@ +//! Independent flat-patch oracles for the Lab Bradford follow-up to #1237. +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +// These frozen RGB8 values were computed outside the renderer in f64 from the +// CIE Lab inverse, the full XYZ adaptation matrix B^-1 diag(B*D65 / B*white) B, +// and the standard sRGB matrix/transfer function. No production helper is used. +// Bradford reference: https://www.color.org/chadtag/ (ICC TN 02-2003). +// White points: D50 [0.96422, 1, 0.82521], E [1, 1, 1], +// D65 [0.95047, 1, 1.08883]. D65 adaptation is the identity. +const SAMPLES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [40.0, 20.0, -60.0], [70.0, -50.0, 30.0], [100.0, 0.0, 0.0]]; +const D50: [[u8; 3]; 4] = [[119, 119, 119], [67, 86, 194], [66, 194, 113], [255, 255, 255]]; +const E: [[u8; 3]; 4] = [[119, 119, 119], [50, 88, 194], [64, 194, 114], [255, 255, 255]]; +const D65: [[u8; 3]; 4] = [[119, 119, 119], [19, 90, 194], [77, 193, 114], [255, 255, 255]]; + +fn patch(space: &str, operands: &str) -> [u8; 4] { + let content = format!("/C cs {operands} sc 0 0 20 20 re f"); + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 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 20 20] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{space} {operands}: {:?}", page.error); + assert_eq!((page.width, page.height), (20, 20)); + page.rgba.get((10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4).unwrap().try_into().unwrap() +} + +fn assert_rgb(actual: [u8; 4], expected: [u8; 3], context: &str) { + assert_eq!(actual[3], 255, "{context}: {actual:?}"); + for (got, want) in actual.into_iter().zip(expected) { + assert!(got.abs_diff(want) <= 1, "{context}: got {actual:?}, expected {expected:?}"); + } +} + +fn check_white(white: &str, expected: [[u8; 3]; 4]) { + let space = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + for (sample, expected) in SAMPLES.into_iter().zip(expected) { + let operands = format!("{} {} {}", sample[0], sample[1], sample[2]); + assert_rgb(patch(&space, &operands), expected, &format!("{white} Lab {sample:?}")); + } +} + +#[test] +fn lab_bradford_d50_neutral_and_chromatic_oracles() { + check_white("[0.96422 1 0.82521]", D50); +} + +#[test] +fn lab_bradford_equal_energy_neutral_and_chromatic_oracles() { + check_white("[1 1 1]", E); +} + +#[test] +fn lab_bradford_d65_identity_oracles() { + check_white("[0.95047 1 1.08883]", D65); +} + +#[test] +fn lab_bradford_indexed_matches_direct_and_oracle() { + for (white, blue) in [("[0.96422 1 0.82521]", D50[1]), ("[1 1 1]", E[1]), ("[0.95047 1 1.08883]", D65[1])] { + // <66 99 33> maps exactly to L*=40, a*=20, b*=-60. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <669933>]"), "0"); + assert_rgb(indexed, blue, &format!("{white} Indexed blue")); + assert_eq!(indexed, patch(&lab, "40 20 -60"), "{white} direct/Indexed blue"); + + // A non-default Range maps <80 80 80> to L*=128/255*100, a*=b*=0. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-128 127 -128 127] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <808080>]"), "0"); + assert_rgb(indexed, [119, 119, 119], &format!("{white} Indexed neutral")); + assert_eq!(indexed, patch(&lab, "50.19607843137255 0 0"), "{white} direct/Indexed neutral"); + } +} + +#[test] +fn lab_bradford_degenerate_cones_fall_back_to_unadapted_xyz() { + // In f32, B*[1, 1, 7.1964064] has a zero L cone response. + // B*[3e38, 3e38, 3e38] overflows its L/M responses. The guard must + // return the sample XYZ in both cases. Independent unadapted XYZ -> + // sRGB references for Lab (50, 0, 0) are frozen below. + for (white, expected) in [ + ("[1 1 7.1964064]", [0, 130, 255]), + ( + "[300000000000000000000000000000000000000 300000000000000000000000000000000000000 300000000000000000000000000000000000000]", + [255, 255, 255], + ), + ] { + let space = format!("[/Lab << /WhitePoint {white} >>]"); + assert_rgb(patch(&space, "50 0 0"), expected, &format!("{white} degenerate cones")); + } +} + +#[test] +fn lab_bradford_asymmetric_chromatic_range_matches_direct_and_oracle() { + // With a* [-40, 70] and b* [-90, 20], <80 99 33> maps to + // L*=128/255*100, a*=26, b*=-68. Frozen independent D50 oracle: + // RGB (98, 108, 237). The unequal, asymmetric ranges expose swaps + // and default-Range scaling that a neutral lookup cannot detect. + let lab = "[/Lab << /WhitePoint [0.96422 1 0.82521] /Range [-40 70 -90 20] >>]"; + let indexed = patch(&format!("[/Indexed {lab} 0 <809933>]"), "0"); + assert_rgb(indexed, [98, 108, 237], "D50 asymmetric chromatic Range"); + assert_eq!(indexed, patch(lab, "50.19607843137255 26 -68")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d2ee5712c..ae146779f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,3 +39,6 @@ pub enum OpenError { #[error("{0}")] Unsupported(String), } + +#[cfg(test)] +mod lab_bradford_tests; diff --git a/docs/roadmap.md b/docs/roadmap.md index b483b336d..42998b70f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,7 +81,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). Fuzzing runs nightly (`xtask fuzz`). 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 | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | 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). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | diff --git a/vendor/README.md b/vendor/README.md index 94109f443..8fb270f58 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -32,3 +32,10 @@ Later rendering errors, device loss, and non-winit renderer construction are unc Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). + +Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios +computed in Bradford LMS coordinates. CalRGB's existing flat-white and +black-point normalization is unchanged. `lab_bradford_tests` checks independent +D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed +consistency (including an asymmetric chromatic Range), and zero/overflowing +cone-response fallback. diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 3be29d19d..dd2edf241 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -678,6 +678,27 @@ impl Lab { } } + /// Bradford adaptation scales cone responses, so both white points must + /// be expressed in LMS before taking their ratios. CalRGB's separate + /// flat-white/black-point pipeline retains its existing normalization. + fn adapt_to_d65(white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { + let source_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, white_point); + // A malformed but positive XYZ white can still yield a zero or + // overflowing cone response. Leave it unadapted rather than divide by + // zero or propagate a non-finite scale. + if source_lms.iter().any(|v| !v.is_finite() || v.abs() <= f32::EPSILON) { + return *xyz; + } + let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, &CalRgb::D65_WHITEPOINT); + let lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, xyz); + let adapted = [ + lms[0] * destination_lms[0] / source_lms[0], + lms[1] * destination_lms[1] / source_lms[1], + lms[2] * destination_lms[2] / source_lms[2], + ]; + CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_INVERSE_MATRIX, &adapted) + } + /// Inverse of the CIE f(t) helper that maps relative fX/fY/fZ to absolute /// XYZ as a ratio of the white point. fn inverse_lab_f(t: f32) -> f32 { @@ -726,7 +747,7 @@ impl ToRgb for Lab { zw * Self::inverse_lab_f(fz), ]; - let xyz_d65 = CalRgb::normalize_white_point_to_d65(&white_point, &xyz); + let xyz_d65 = Self::adapt_to_d65(&white_point, &xyz); let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65); output.copy_from_slice(&[