honour the /Lab colour space /WhitePoint (#1203) (#1237)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

* fix(color): honour the /Lab colour space /WhitePoint (#1203)

The old path fed every Lab value to the bundled ICC transform as if the
space's white point were D50, so a /Lab space with any other /WhitePoint
(e.g. D65 [0.9505 1 1.089]) rendered off by several levels per channel
and a D65 blue came out 45 levels too red.

Map L*a*b* to XYZ in the space's own white coordinates (ISO 32000-2
§8.6.5.4), Bradford-adapt it to D65 and convert to sRGB directly, as
Poppler does. Missing or degenerate white points fall back to a flat
white and hostile components are clamped without panicking.

Add a render regression that checks the five reported flat patches of
the D65 white-point fixture against their CIE sRGB values.

* Lab: map normalised Indexed palette components onto L*/Range; drop unused LAB.icc

An /Indexed palette over Lab passes its entries as byte/255 (manual_scale),
which the new Lab conversion read as raw L*a*b* and drew near black. Map
them onto L* 0..100 and the a*/b* /Range, with a regression test. Record the
patch as hayro-interpret (25) in vendor/README.md, and remove LAB.icc (no
longer referenced) with its ATTRIBUTION, NOTICE and assets README entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Main gates: rustfmt tests/viewer.rs; a type alias for the refresh-steps array (clippy type_complexity)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Cieślik 2026-10-12 08:08:21 +02:00 committed by GitHub
parent 13637b8440
commit bde9dc52fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 179 additions and 67 deletions

View File

@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (269)
## In this repository (268)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -221,7 +221,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space conversion (vendored hayro-interpret) |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |

View File

@ -2353,17 +2353,6 @@ kind = "data"
usage = "Default CMYK colour conversion (vendored hayro-interpret)"
sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0"
[[asset]]
path = "vendor/hayro-interpret/assets/LAB.icc"
title = "Lab abstract ICC profile"
author = "The Hayro Authors (generated with LittleCMS 2)"
source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)"
licence = "MIT OR Apache-2.0"
licence_file = "vendor/hayro-interpret/LICENSE-MIT"
kind = "data"
usage = "Lab colour space conversion (vendored hayro-interpret)"
sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59"
[[bundled]]
crate = "epaint_default_fonts"
version = "0.36.2"

3
NOTICE
View File

@ -35,8 +35,7 @@ list (author, source, license) is in ATTRIBUTION.md.
- lopdf (bootstrap document inspection, via crates.io) — MIT.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and
LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
Required notices:

View File

@ -3925,7 +3925,8 @@ fn edits_let_go_of_the_previous_working_file() {
|| Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false };
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
// A full refresh, a comment refresh, a content refresh, then undo and redo.
let steps: [(&str, Box<dyn Fn(&mut Session)>); 5] = [
type Step<'a> = Box<dyn Fn(&mut Session) + 'a>;
let steps: [(&str, Step<'_>); 5] = [
("rotate", Box::new(|s: &mut Session| s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap())),
("comment", Box::new(move |s: &mut Session| s.apply(id, comment()).unwrap())),
("watermark", Box::new(move |s: &mut Session| s.apply(id, watermark()).unwrap())),

View File

@ -1905,6 +1905,98 @@ mod tests {
assert!(remainder.is_empty());
assert!(pixels.iter().all(|pixel| *pixel == [0, 0, 255, 255]));
}
/// #1203: a /Lab colour space must honour its own /WhitePoint. The old path
/// fed every Lab value to the ICC transform as if the white point were D50,
/// shifting non-D50 pages several levels per channel (for example a D65
/// blue came out 45 levels too red). Conversion and expected values follow
/// ISO 32000-2 §8.6.5.4 with the space's own /WhitePoint [0.9505 1 1.089].
#[test]
fn lab_honours_the_space_white_point() {
use super::*;
let render = |l: f32, a: f32, b: f32| -> [u8; 4] {
let content = format!("/L cs {l} {a} {b} sc 0 0 200 200 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 200 200] /Resources << /ColorSpace << /L [/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>] >> >> /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,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
});
assert!(page.error.is_none(), "Lab ({l} {a} {b}): {:?}", page.error);
assert_eq!((page.width, page.height), (200, 200));
page.rgba[(100 * page.width as usize + 100) * 4..][..4].try_into().unwrap()
};
for (l, a, b, expected) in [
(40.0, 20.0, -60.0, [19, 90, 194, 255]),
(70.0, -50.0, 30.0, [77, 193, 114, 255]),
(60.0, 0.0, -40.0, [87, 148, 215, 255]),
(50.0, 60.0, 40.0, [217, 61, 55, 255]),
(30.0, 40.0, 20.0, [130, 37, 42, 255]),
] {
let got = render(l, a, b);
for (channel, (got, want)) in got.iter().zip(expected).enumerate() {
assert!((*got).abs_diff(want) <= 2, "Lab ({l} {a} {b}) channel {channel}: got {got:?}, expected {expected:?}");
}
}
}
/// #1203: an `/Indexed` palette over Lab looks its entries up as
/// `byte / 255`, which must map onto L* 0..100 and the a*/b* /Range. They
/// were read as raw L*a*b* (L* below 1), so such palettes drew near black.
#[test]
fn indexed_palettes_over_lab_keep_their_colours() {
use super::*;
let lab = "[/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>]";
let render = |content: &str| -> [u8; 4] {
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 200 200] /Resources << /ColorSpace << /L {lab} /I [/Indexed {lab} 1 <000000 80CCB2>] >> >> /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,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
});
assert!(page.error.is_none(), "{content}: {:?}", page.error);
assert_eq!((page.width, page.height), (200, 200));
let start = (100 * page.width as usize + 100) * 4;
let mut pixel = [0; 4];
pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
pixel
};
// Palette entry 1 is <80 CC B2>: L* = 128/255·100, a* = -100 + 204/255·200,
// b* = -100 + 178/255·200, about Lab (50.2 60 39.6): a warm red.
let indexed = render("/I cs 1 sc 0 0 200 200 re f");
let direct = render(&format!(
"/L cs {} {} {} sc 0 0 200 200 re f",
128.0 / 255.0 * 100.0,
-100.0 + 204.0 / 255.0 * 200.0,
-100.0 + 178.0 / 255.0 * 200.0
));
for (channel, (got, want)) in indexed.iter().zip(direct).enumerate() {
assert!(got.abs_diff(want) <= 1, "channel {channel}: indexed {indexed:?}, direct {direct:?}");
}
assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}");
}
#[test]
fn watchdog_skips_a_stuck_page_and_keeps_rendering() {
use super::*;

View File

@ -193,9 +193,7 @@ fn marking_while_searching_does_not_jitter_or_reset_search_state() {
assert!(h.state().views[0].goto.is_none());
// User draws or marks an annotation on page 0
let shape = pdfcraft_engine::Shape::Ink {
strokes: vec![vec![[10.0, 10.0], [20.0, 20.0]]],
};
let shape = pdfcraft_engine::Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 20.0]]] };
let stroke = pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
style: pdfcraft_engine::Style::default_for(&shape),
@ -206,11 +204,7 @@ fn marking_while_searching_does_not_jitter_or_reset_search_state() {
h.state_mut().apply_edit(stroke);
// Right after applying the edit, search matches and current match must still be valid
assert_eq!(
h.state().views[0].find.as_ref().unwrap().current,
current_before,
"applying an annotation must not wipe current search match"
);
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, current_before, "applying an annotation must not wipe current search match");
// Let the canvas process the reload
let reload_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);

2
vendor/README.md vendored

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -11,5 +11,3 @@ Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. http://www.foxitsoftware.com
```
The LAB.icc profile was generated with LCMS2.

View File

@ -11,7 +11,7 @@ use hayro_syntax::object::Stream;
use hayro_syntax::object::dict::keys::*;
use moxcms::{
ColorProfile, DataColorSpace, Layout, Transform8BitExecutor, TransformF32Executor,
TransformOptions, Xyzd,
TransformOptions,
};
use smallvec::{SmallVec, ToSmallVec, smallvec};
use std::borrow::Cow;
@ -601,11 +601,7 @@ impl CalRgb {
Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_flat)
}
fn normalize_white_point_to_d65(
&self,
source_white_point: &[f32; 3],
xyz: &[f32; 3],
) -> [f32; 3] {
fn normalize_white_point_to_d65(source_white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] {
let lms = Self::matrix_product(&Self::BRADFORD_SCALE_MATRIX, xyz);
let lms_d65 = Self::to_d65(source_white_point, &lms);
Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_d65)
@ -637,7 +633,7 @@ impl ToRgb for CalRgb {
let xyz_flat = self.normalize_white_point_to_flat(&self.white_point, &xyz);
let xyz_black = Self::compensate_black_point(&self.black_point, &xyz_flat);
let xyz_d65 = self.normalize_white_point_to_d65(&Self::FLAT_WHITEPOINT, &xyz_black);
let xyz_d65 = Self::normalize_white_point_to_d65(&Self::FLAT_WHITEPOINT, &xyz_black);
let srgb_xyz = Self::matrix_product(&Self::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65);
output.copy_from_slice(&[
@ -654,58 +650,93 @@ impl ToRgb for CalRgb {
#[derive(Debug, Clone)]
pub(crate) struct Lab {
range: [f32; 4],
profile: ICCProfile,
white_point: [f32; 3],
}
impl Lab {
fn new(dict: &Dict<'_>) -> Option<Self> {
let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
// Not sure how this should be used.
let _black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
let range = dict
.get::<[f32; 4]>(RANGE)
.unwrap_or([-100.0, 100.0, -100.0, 100.0]);
let mut profile = ColorProfile::new_from_slice(include_bytes!("../assets/LAB.icc")).ok()?;
profile.white_point = Xyzd::new(
white_point[0] as f64,
white_point[1] as f64,
white_point[2] as f64,
);
Some(Self { range, white_point })
}
let profile = ICCProfile::new_from_src_profile(
profile, false,
// This flag is only used to scale the values to [0.0, 1.0], but
// we already take care of this in the `convert_f32` method.
// Therefore, leave this as false, even though this is a LAB profile.
false, 3,
)?;
/// The white point the space's L\*a\*b\* values are relative to (ISO 32000-2
/// §8.6.5.4). A missing or degenerate entry falls back to a flat white point
/// so hostile input cannot break the conversion.
fn usable_white_point(&self) -> [f32; 3] {
let white_point = self.white_point;
if white_point
.iter()
.all(|component| component.is_finite() && *component > 0.0)
{
white_point
} else {
[1.0, 1.0, 1.0]
}
}
Some(Self { range, profile })
/// 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 {
const DELTA: f32 = 6.0 / 29.0;
if t > DELTA {
t * t * t
} else {
3.0 * DELTA * DELTA * (t - 4.0 / 29.0)
}
}
}
impl ToRgb for Lab {
fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
if !manual_scale {
// moxcms expects values between 0.0 and 1.0, so we need to undo
// the scaling.
let [min_a, max_a, min_b, max_b] = self.range;
let white_point = self.usable_white_point();
let [xw, yw, zw] = white_point;
let input = input
.chunks_exact(3)
.flat_map(|i| {
let l = i[0] / 100.0;
let a = (i[1] + 128.0) / 255.0;
let b = (i[2] + 128.0) / 255.0;
[l, a, b]
})
.collect::<Vec<_>>();
self.profile.convert_f32(&input, output, manual_scale)
for (input, output) in input.chunks_exact(3).zip(output.chunks_exact_mut(3)) {
// PdfCraft patch: `manual_scale` means the components arrive
// normalised to [0, 1] (an `/Indexed` palette over Lab looks its
// bytes up as `byte / 255`). Map them linearly onto L* 0..100 and
// the a*/b* /Range (ISO 32000-2 §8.6.6.3) before converting.
let (l, a, b) = if manual_scale {
(
clamp_component(input[0], 0.0, 1.0) * 100.0,
min_a + clamp_component(input[1], 0.0, 1.0) * (max_a - min_a),
min_b + clamp_component(input[2], 0.0, 1.0) * (max_b - min_b),
)
} else {
self.profile.convert_f32(input, output, manual_scale)
(input[0], input[1], input[2])
};
let l = clamp_component(l, 0.0, 100.0);
let a = clamp_component(a, min_a, max_a);
let b = clamp_component(b, min_b, max_b);
// L*a*b* is relative to the space's own /WhitePoint. Map it to XYZ
// in that white's coordinates, Bradford-adapt to D65 and convert to
// sRGB (ISO 32000-2 §8.6.5.4).
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let xyz = [
xw * Self::inverse_lab_f(fx),
yw * Self::inverse_lab_f(fy),
zw * Self::inverse_lab_f(fz),
];
let xyz_d65 = CalRgb::normalize_white_point_to_d65(&white_point, &xyz);
let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65);
output.copy_from_slice(&[
f32_to_u8(CalRgb::srgb_transfer_function(srgb[0])),
f32_to_u8(CalRgb::srgb_transfer_function(srgb[1])),
f32_to_u8(CalRgb::srgb_transfer_function(srgb[2])),
]);
}
Some(())
}
}
@ -1029,6 +1060,15 @@ fn f32_to_u8(val: f32) -> u8 {
(val * 255.0 + 0.5) as u8
}
/// Clamp an input-derived colour component to `[lo, hi]` without panicking on
/// NaN or on a reversed range. Non-finite values map to zero.
fn clamp_component(value: f32, lo: f32, hi: f32) -> f32 {
if !value.is_finite() {
return 0.0;
}
value.max(lo).min(hi)
}
#[derive(Debug, Clone)]
/// A color.
pub struct Color {