craft-cms: the changes PhotoCraft made to its copy since extraction (#858)

* craft-cms: the changes PhotoCraft made to its copy since extraction

PhotoCraft kept editing crates/cms after craft-cms was extracted from it at
storytold/photocraft@b3222ee8. This ports its 41 commits up to
storytold/photocraft@0143b148 with a three-way merge per file, keeping the
changes craft-cms made on its own (Result-returning API, white preservation,
FOGRA39, named colours, profile ID).

Ported: the .3dl shaper, Mesh header, UTF-16 and .look fixes in lutfile; the
checked ICC offsets and the per-profile curve sample budget; same_bytes and the
byte-confirmed transform cache; the blocked matrix/TRC path and the 8-bit
first-stage table; GpuMatrixShaper; cicp_rgb; the ACEScg and Dot Gain 20 %
built-ins (the latter now the default gray); bisection for a table's flat ends;
PhotoCraft spelled with a capital C in the built-in descriptions, with the
shipped coated CMYK profile's bytes as PhotoCraft regenerated them.

Not taken, because craft-cms already does it another way: Curve::inverse_table
(inverse64_many), the effective-dimension loop in multilinear interpolation,
the curve-count and mluc checks, and the .cube DOMAIN writer.

The 8-bit first-stage table checks device white as the exact path does.

Refs #779

* craft-cms: Lut3d::from_fn

PhotoCraft's proof and HDR display tables are built from a chain of several
transforms, which it wrote into Lut3d's fields. Those are private here, so
from_fn samples any RGB function, with the same size bounds as from_transform.

Refs #779

* craft-cms: README names Lut3d::from_fn
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:37:23 -05:00
1 parent e4a6b68e5a
commit 628a235ae5
16 files changed
+1620 -94

No files matched your search

+17 -4
View File
@@ -30,8 +30,14 @@ Buffers are checked once per call, before any pixel is written.
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture.
* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture;
`Lut3d::from_fn(size, f)` samples any RGB function.
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
display shaders. Float tables retain near-black detail; unsupported chains return `None`
for the 3D LUT fallback. No GPU dependency is required by this crate.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `builtin::cicp_rgb(primaries, transfer, linear)`: the RGB profile for an ITU-T H.273 colour
description (a HEIF `nclx` box, a video stream).
* `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUT
files; `lutfile::builtin(id)` returns generated looks (`lutfile::BUILTIN`).
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding
@@ -59,6 +65,10 @@ Buffers are checked once per call, before any pixel is written.
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
(native only).
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
@@ -73,7 +83,8 @@ Buffers are checked once per call, before any pixel is written.
## Built-in profiles (all CC0-1.0, generated by this crate)
The profile descriptions and copyright text keep the PhotoCraft names they were first
published with, so profiles embedded in existing files still match byte for byte.
published with (spelled "PhotoCraft" since PhotoCraft's #5614), so profiles embedded in
existing files still match byte for byte.
| id | description |
|---|---|
@@ -82,10 +93,12 @@ published with, so profiles embedded in existing files still match byte for byte
| `adobe-rgb-compat` | Adobe RGB (1998)-compatible (published primaries, gamma 563/256) |
| `prophoto-compat` | ProPhoto-compatible (ROMM primaries, D50, gamma 1.8) |
| `linear-srgb` | sRGB primaries, linear |
| `aces-cg` | ACEScg: AP1 primaries, ACES white (~D60), linear |
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve |
| `dot-gain-20-compat` | gray, gamma 1.732, fitted to Photoshop's Dot Gain 20 % (default gray working space) |
| `lab-d50` | Lab identity (v4 encoding) |
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK |
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK |
| `coated-fogra39` | Coated FOGRA39 (fitted, craft-cms): fitted to Fogra's FOGRA39 data, 330% TAC, medium GCR |
### The CMYK profiles
Binary file not shown.
+99 -18
View File
@@ -34,12 +34,18 @@ pub enum Builtin {
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
AcesCg,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
GrayGamma22,
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
SGray,
/// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure
/// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red
/// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile.
DotGain20Compat,
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
@@ -51,15 +57,17 @@ pub enum Builtin {
}
impl Builtin {
pub const ALL: [Builtin; 11] = [
pub const ALL: [Builtin; 13] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::AcesCg,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
Builtin::DotGain20Compat,
Builtin::LabD50,
Builtin::CoatedCmyk,
Builtin::CoatedFogra39,
@@ -73,9 +81,11 @@ impl Builtin {
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::AcesCg => "aces-cg",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
Builtin::DotGain20Compat => "dot-gain-20-compat",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
Builtin::CoatedFogra39 => "coated-fogra39",
@@ -87,13 +97,15 @@ impl Builtin {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
Builtin::CoatedFogra39 => COATED_FOGRA39_DESCRIPTION,
}
@@ -111,6 +123,8 @@ impl Builtin {
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
"fogra39" | "coatedfogra39" => Some(Builtin::CoatedFogra39),
@@ -151,7 +165,7 @@ impl Builtin {
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
static CELLS: [OnceLock<Profile>; 13] = [const { OnceLock::new() }; 13];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
@@ -163,11 +177,12 @@ fn profile_name_key(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect()
}
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default
/// working gray), the coated CMYK profile, Lab D50.
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
Some(match cs {
ColorSpace::Rgb => Builtin::Srgb.profile(),
ColorSpace::Gray => Builtin::SGray.profile(),
ColorSpace::Gray => Builtin::DotGain20Compat.profile(),
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
ColorSpace::Lab => Builtin::LabD50.profile(),
_ => return None,
@@ -182,7 +197,7 @@ pub fn srgb() -> &'static Profile {
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
@@ -211,6 +226,8 @@ const P3_SPACE: RgbSpace = rgb_space!([[0.680, 0.320], [0.265, 0.690], [0.150, 0
const ADOBE_SPACE: RgbSpace = rgb_space!([[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], D65);
const ROMM_SPACE: RgbSpace = rgb_space!([[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], math::D50);
const BT2020_SPACE: RgbSpace = rgb_space!([[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], D65);
/// ACEScg: the AP1 primaries with the ACES white point (~D60, xy 0.32168/0.33767, the ACES 2065-1 neutral).
const AP1_SPACE: RgbSpace = rgb_space!([[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], math::xy_to_xyz([0.32168, 0.33767]));
/// Colorant matrix adapted to D50 and quantized to s15Fixed16, with each row nudged so that
/// RGB (1, 1, 1) maps exactly to the stored D50 white (keeps neutrals neutral).
@@ -321,6 +338,46 @@ pub const COATED_FOGRA39_ICC: &[u8] = include_bytes!("../profiles/craft-coated-f
/// with `CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release regen`).
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
fn rec709_trc() -> Curve {
let a = 1.099;
Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }
}
/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video
/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB,
/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the
/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are
/// held, since no ICC curve represents those transfers. `None` for anything else.
pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option<Profile> {
let (prim, name) = match primaries {
1 => (SRGB_SPACE, "BT.709"),
9 => (BT2020_SPACE, "Rec. ITU-R BT.2020"),
12 => (P3_SPACE, "Display P3"),
_ => return None,
};
let transfer = if linear { 8 } else { transfer };
let builtin = match (primaries, transfer) {
(1, 13) => Some(Builtin::Srgb),
(1, 8) => Some(Builtin::LinearSrgb),
(12, 13) => Some(Builtin::DisplayP3),
(9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020),
_ => None,
};
if let Some(b) = builtin {
return Some(b.profile().clone());
}
let (trc, curve) = match transfer {
8 => (Curve::Gamma(1.0), "linear"),
13 => (srgb_trc(), "sRGB curve"),
1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"),
4 => (Curve::Gamma(2.2), "gamma 2.2"),
5 => (Curve::Gamma(2.8), "gamma 2.8"),
_ => return None,
};
Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, trc))
}
fn build(b: Builtin) -> Profile {
match b {
Builtin::Srgb => rgb(b.description(), SRGB_SPACE, srgb_trc()),
@@ -328,14 +385,11 @@ fn build(b: Builtin) -> Profile {
Builtin::AdobeRgbCompat => rgb(b.description(), ADOBE_SPACE, Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), ROMM_SPACE, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), SRGB_SPACE, Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
rgb(b.description(), BT2020_SPACE, trc)
}
Builtin::AcesCg => rgb(b.description(), AP1_SPACE, Curve::Gamma(1.0)),
Builtin::Rec2020 => rgb(b.description(), BT2020_SPACE, rec709_trc()),
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
Builtin::LabD50 => lab(),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test).
Builtin::CoatedCmyk => shipped_cmyk(COATED_CMYK_ICC, b),
@@ -369,3 +423,30 @@ fn shipped_cmyk(bytes: &[u8], b: Builtin) -> Profile {
hash: Default::default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cicp_maps_onto_the_builtins_and_linear_bt2020() {
let desc = |p: Option<Profile>| p.map(|p| p.description);
assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string()));
assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string()));
assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string()));
assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string()));
// An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve.
let hlg = cicp_rgb(9, 18, true).unwrap();
assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)]));
assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix);
assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description);
assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile");
// HLG and PQ have no ICC curve; unknown primaries have no profile.
assert!(cicp_rgb(9, 18, false).is_none());
assert!(cicp_rgb(9, 16, false).is_none());
assert!(cicp_rgb(2, 13, false).is_none());
assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none());
}
}
+14
View File
@@ -350,4 +350,18 @@ mod tests {
narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap();
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}");
}
#[test]
fn degenerate_dimensions_do_not_change_the_result() {
// Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary. Interpolating
// over the effective dimensions only gives the same answer as the function sampled.
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f).unwrap();
let mut got = [0.0];
for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] {
c.eval_multilinear(&p, &mut got).unwrap();
let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]);
}
}
}
+24 -2
View File
@@ -305,15 +305,22 @@ fn table_inverse_with(t: &[f32], y: f32, edge: impl Fn(bool) -> f32) -> f32 {
/// For a flat run at the table start (`start = true`) the last index of the run, or for a flat
/// run at the end the first index of it, as a normalised input.
///
/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long
/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's
/// length times the image's.
fn edge_index(t: &[f32], start: bool) -> f32 {
let n = t.len();
if start {
let v = t[0];
let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1);
// The first value that differs, given they do so from there on.
let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7);
let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) };
i as f32 / (n - 1) as f32
} else {
let v = t[n - 1];
let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1);
// The start of the trailing run equal to the last value.
let i = t.partition_point(|x| (*x - v).abs() > 1e-7);
i as f32 / (n - 1) as f32
}
}
@@ -390,4 +397,19 @@ mod tests {
let g = Curve::Gamma(2.2);
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
}
#[test]
fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() {
// Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls.
let mut t = vec![0.0f32; 4_000_000];
t.push(1.0);
let c = Curve::Table(t);
let started = std::time::Instant::now();
let mut last = 0.0;
for _ in 0..200_000 {
last = c.eval_inverse(0.0);
}
assert!(last > 0.99, "the flat start ends at the last zero: {last}");
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Separable RGB display transforms without a coarse 3D interpolation near black.
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
/// transform. Values are floats independent of the document's pixel depth.
///
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
/// [`Self::matrix_rows`] to that result, then sample the output table at
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
/// separately between its two adjacent table entries. These nonuniform output samples
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
///
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
/// [`crate::Lut3d`] fallback.
#[derive(Clone, Debug, PartialEq)]
pub struct GpuMatrixShaper {
matrix: [[f32; 4]; 3],
input: Vec<[f32; 4]>,
output: Vec<[f32; 4]>,
}
impl GpuMatrixShaper {
/// The input table has one more entry than this number of equal intervals.
pub const INPUT_INTERVALS: usize = 4096;
/// Output entries are spaced uniformly in the fourth root of the linear value.
pub const OUTPUT_ENTRIES: usize = 4096;
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
self.matrix
}
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
pub fn input_table(&self) -> &[[f32; 4]] {
&self.input
}
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
pub fn output_table(&self) -> &[[f32; 4]] {
&self.output
}
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
if !matrix.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
for row in matrix {
let magnitude = row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>()
+ f64::from(row[3]).abs();
if magnitude > f64::from(f32::MAX) {
return None;
}
}
Some(Self { matrix, input, output })
}
}
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
if let Some(tables) = tables
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
{
return None;
}
let mut packed: Vec<[f32; 4]> = Vec::new();
// Both lengths are fixed constants, but a failed allocation still means the caller
// should retain its existing fallback rather than abort while opening a document.
packed.try_reserve_exact(entries).ok()?;
for i in 0..entries {
let rgb = match tables {
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
None => [identity(i as f32 / (entries - 1) as f32); 3],
};
if !rgb.iter().all(|v| v.is_finite()) {
return None;
}
if let Some(previous) = packed.last()
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
{
return None;
}
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
}
Some(packed)
}
#[cfg(test)]
mod tests {
use super::*;
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
#[test]
fn rejects_invalid_tables_and_nonfinite_values() {
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
let mut invalid = inputs.clone();
invalid[0].pop();
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid = inputs.clone();
invalid[1][123] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
let mut invalid = outputs.clone();
invalid[2][321] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
let mut invalid = IDENTITY;
invalid[1][3] = v;
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
}
}
#[test]
fn rejects_finite_data_that_overflows_shader_arithmetic() {
let mut matrix = IDENTITY;
matrix[0] = [f32::MAX; 4];
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
inputs[0][100] = -f32::MAX;
inputs[0][101] = f32::MAX;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
}
}
+3
View File
@@ -13,6 +13,7 @@
//! * [`NamedColors`] reads a named-colour profile's spot colours and converts them into a
//! destination profile.
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
//!
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
//! CIE 15, and Adobe's published black point compensation paper.
@@ -22,6 +23,7 @@ pub mod builtin;
pub mod clut;
pub mod curve;
pub mod gamut;
mod gpu_matrix_shaper;
pub mod lut3d;
pub mod lutfile;
pub mod math;
@@ -36,6 +38,7 @@ pub use builtin::Builtin;
pub use clut::Clut;
pub use curve::Curve;
pub use gamut::GamutCheck;
pub use gpu_matrix_shaper::GpuMatrixShaper;
pub use lut3d::Lut3d;
pub use named::{NamedColor, NamedColors};
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
+32
View File
@@ -37,6 +37,25 @@ impl Lut3d {
Ok(Lut3d { size, data })
}
/// Samples `f` (RGB in, RGB out) on a `size`³ grid, `size` from 2 to [`MAX_SIZE`]: a display
/// chain that is more than one [`Transform`].
pub fn from_fn(size: usize, mut f: impl FnMut([f32; 3]) -> [f32; 3]) -> Result<Lut3d, CmsError> {
if !(2..=MAX_SIZE).contains(&size) {
return Err(invalid_arg(format!("3D LUT size {size} (2 to {MAX_SIZE})")));
}
let s = (size - 1) as f32;
let mut data = Vec::with_capacity(size * size * size);
for b in 0..size {
for g in 0..size {
for r in 0..size {
let v = f([r as f32 / s, g as f32 / s, b as f32 / s]);
data.push([v[0], v[1], v[2], 1.0]);
}
}
}
Ok(Lut3d { size, data })
}
/// Edge length.
pub fn size(&self) -> usize {
self.size
@@ -156,4 +175,17 @@ mod tests {
assert_eq!(f32_to_f16(1.0), 0x3C00);
assert_eq!(f32_to_f16(1e6), 0x7C00);
}
#[test]
fn from_fn_samples_red_fastest_and_bounds_the_size() {
let lut = Lut3d::from_fn(3, |[r, g, b]| [r, g * 0.5, 1.0 - b]).unwrap();
assert_eq!(lut.size(), 3);
assert_eq!(lut.data().len(), 27);
assert_eq!(lut.data()[1], [0.5, 0.0, 1.0, 1.0]);
assert_eq!(lut.data()[3], [0.0, 0.25, 1.0, 1.0]);
assert_eq!(lut.data()[26], [1.0, 0.5, 0.0, 1.0]);
for size in [0, 1, MAX_SIZE + 1, usize::MAX] {
assert!(matches!(Lut3d::from_fn(size, |v| v), Err(CmsError::InvalidArgument(_))), "{size}");
}
}
}
+715 -23
View File
@@ -88,16 +88,63 @@ impl LutFile {
}
}
/// A LUT file format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Cube,
ThreeDl,
Look,
}
impl Format {
/// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed
/// (XML is `.look`, anything else `.cube`).
pub fn detect(file_name: &str, bytes: &[u8]) -> Self {
let lower = file_name.to_ascii_lowercase();
if lower.ends_with(".3dl") {
Format::ThreeDl
} else if lower.ends_with(".look") || decode_text(bytes).trim_start().starts_with('<') {
Format::Look
} else {
Format::Cube
}
}
/// The file extension, without the dot.
pub fn extension(self) -> &'static str {
match self {
Format::Cube => "cube",
Format::ThreeDl => "3dl",
Format::Look => "look",
}
}
}
/// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing).
pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
let lower = file_name.to_ascii_lowercase();
let text = String::from_utf8_lossy(bytes);
if lower.ends_with(".3dl") {
parse_3dl(&text)
} else if lower.ends_with(".look") || text.trim_start().starts_with('<') {
parse_look(&text)
} else {
parse_cube(&text)
parse_as(Format::detect(file_name, bytes), bytes)
}
/// Parses a LUT in a known format.
pub fn parse_as(format: Format, bytes: &[u8]) -> Result<LutFile, LutError> {
let text = decode_text(bytes);
match format {
Format::Cube => parse_cube(&text),
Format::ThreeDl => parse_3dl(&text),
Format::Look => parse_look(&text),
}
}
/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8.
fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> {
let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect();
String::from_utf16_lossy(&units).into()
};
match bytes {
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
_ => String::from_utf8_lossy(bytes),
}
}
@@ -108,7 +155,14 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]);
let mut rows: Vec<f32> = Vec::new();
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
// A '#' inside a quoted TITLE is part of the title, not a comment.
let raw = line.trim_start();
let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0);
let line = match raw[skip..].find('#') {
Some(i) => &raw[..skip + i],
None => raw,
}
.trim();
if line.is_empty() {
continue;
}
@@ -172,41 +226,226 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
err("no LUT_3D_SIZE in .cube file")
}
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
/// varying fastest; the output bit depth is inferred from the largest value.
/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh <inBits> <outBits>` line, an optional
/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The
/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to
/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube
/// root of the row count and the range is inferred from the largest value (#9121). A shaper that
/// isn't the even ramp is applied before the table, as Photoshop does (#8982): see [`apply_shaper`].
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
let mut mesh: Option<(u32, u32)> = None;
let mut rows: Vec<[f64; 3]> = Vec::new();
// How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3)
// is otherwise indistinguishable from a node row.
let mut first_len: Option<usize> = None;
// Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is
// on the 12-bit scale, as Photoshop 27.11 reads it (#9121).
let mut header = false;
let mut shaper: Option<Vec<f64>> = None;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
// A header is `3DMESH` or starts with a word (`Mesh 4 12`): a letter, and not a number.
// `NaN` and `inf` are numbers, and `0oops` starts like one: those rows are read strictly.
let Some(first) = line.split_whitespace().next() else { continue };
if first.eq_ignore_ascii_case("3DMESH") || first.starts_with(char::is_alphabetic) && first.parse::<f64>().is_err() {
header = true;
// The only header keyword that carries data. A malformed or out-of-range `Mesh`
// line is ignored, as every text line always was.
let mut it = line.split_whitespace();
if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) {
// A declaration is exactly two integer fields after the keyword. Anything else
// (a non-numeric field, too few or too many) is not a declaration: `mesh` stays
// unset so headerless inference applies, rather than recovering numbers from
// the wrong positions (#9078).
let fields: Vec<&str> = it.collect();
if let [a, b] = fields.as_slice()
&& let (Ok(n), Ok(bits)) = (a.parse::<u32>(), b.parse::<u32>())
&& (1..=7).contains(&n)
&& (1..=16).contains(&bits)
{
mesh = Some((n, bits));
}
}
continue;
}
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
// Every token is a number: a malformed one is refused rather than dropped, which could
// turn a shaper into an even ramp and lose it silently.
let v: Vec<f64> = line.split_whitespace().map(str::parse).collect::<Result<_, _>>().map_err(|_| LutError(format!("bad line `{line}`")))?;
first_len.get_or_insert(v.len());
if v.len() == 3 {
rows.push([v[0], v[1], v[2]]);
} else if v.len() >= 2 {
// Two values are a 2-point shaper (a 2³ table's), not something to drop.
// The input shaper: one row of values ahead of the table, and only one.
if shaper.is_some() || !rows.is_empty() {
return err(format!("a second shaper row `{line}`"));
}
shaper = Some(v);
}
// Lines with more than three numbers are the input shaper; it is assumed linear.
}
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) };
let (n, nodes, scale) = match mesh {
Some((in_bits, out_bits)) => {
let n = 2usize.pow(in_bits) + 1;
let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?;
// One row more than the declared cube, and the first row has exactly `n` values:
// that row is the shaper, not a 28th node of a 3³ cube (#9078).
let nodes = if shaper.is_none() && rows.len() == expected + 1 && first_len == Some(n) {
shaper = rows.first().map(|row| row.to_vec());
&rows[1..]
} else {
&rows[..]
};
if nodes.len() != expected {
return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len()));
}
// Under a valid declaration the node codes are integers in the declared range, so
// the declaration wins for every in-range value — including a small max, where 1 is
// 1/declared of full output, not full output itself (#9073). Only a node beyond the
// declared range is a wrong declaration, and the old inference keeps such a file
// loading as before.
let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0;
(n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared })
}
None => {
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
(n, &rows[..], inferred_3dl_scale(max, header))
}
};
let mut data = vec![0.0f32; n * n * n * 3];
for (i, row) in rows.iter().enumerate() {
for (i, row) in nodes.iter().enumerate() {
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
let at = ((b * n + g) * n + r) * 3;
for k in 0..3 {
data[at + k] = (row[k] / scale) as f32;
}
}
LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
let lut = LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()?;
Ok(match shaper {
Some(shaper) => apply_shaper(lut, &shaper)?,
None => lut,
})
}
/// Bakes a `.3dl` shaper into the table. Photoshop applies the shaper row as a 1-D curve on each
/// input channel ahead of the 3-D table (input `x` reads the shaper, sampled evenly over 0..1,
/// at `x`, interpolated linearly), so each node of the table becomes the table's own trilinear
/// value at the shaped input. An even ramp (the usual shaper) changes nothing and is skipped.
/// The table is baked on a grid holding both its own nodes and the shaper's knots: exact for an
/// identity table, an approximation at that resolution otherwise. A shaper that would need more
/// than [`MAX_SIZE`] nodes for that, or that holds a value that isn't finite, is refused.
fn apply_shaper(lut: LutFile, shaper: &[f64]) -> Result<LutFile, LutError> {
let m = shaper.len();
if m < 2 {
return Ok(lut);
}
// Apply or refuse: a shaper that can't be applied never leaves the table to render alone.
if shaper.iter().any(|v| !v.is_finite()) {
return err("the .3dl shaper holds a value that isn't finite");
}
// The shaper's own depth, from 10 bits up: a 0..1023 ramp is the even ramp with or without a header.
let scale = inferred_3dl_scale(shaper.iter().fold(0.0f64, |a, &b| a.max(b)), true);
let even = |k: usize| scale * k as f64 / (m - 1) as f64;
// Shapers round their ramp: within one code is still the even ramp. An integer shaper's code
// is 1; a float shaper (0..1) is held to one 10-bit step.
let code = if scale > 1.0 { 1.0 } else { 1.0 / 1023.0 };
if shaper.iter().enumerate().all(|(k, &v)| (v - even(k)).abs() <= code) {
return Ok(lut);
}
// Scaled to 0..1 first, so interpolating between two extreme values can't overflow.
let shaped: Vec<f64> = shaper.iter().map(|v| v / scale).collect();
let curve = |x: f64| -> f32 {
let p = x.clamp(0.0, 1.0) * (m - 1) as f64;
let i = (p.floor() as usize).min(m - 2);
let f = p - i as f64;
let (a, b) = (shaped.get(i).copied().unwrap_or(0.0), shaped.get(i + 1).copied().unwrap_or(0.0));
(a + (b - a) * f).clamp(0.0, 1.0) as f32
};
// The bake grid holds both the table's nodes and the shaper's knots (lcm of their intervals),
// so the curve keeps its breakpoints whatever the table's size: exact on an identity table. A
// grid that would pass MAX_SIZE is refused: sampled more coarsely, a bent shaper could read as
// even and be lost without a word.
let gcd = |mut a: usize, mut b: usize| {
while b != 0 {
(a, b) = (b, a % b);
}
a
};
let (a, k) = (lut.size - 1, m - 1);
let aligned = (a / gcd(a, k)).checked_mul(k).map(|l| l + 1).filter(|&l| l <= MAX_SIZE);
let Some(n) = aligned else {
return err(format!("the .3dl shaper's {m} points can't be kept on a table of at most {MAX_SIZE} nodes"));
};
let step = (n - 1) as f64;
let mut out = LutFile { size: n, data: vec![0.0; n * n * n * 3], ..lut.clone() };
for b in 0..n {
for g in 0..n {
for r in 0..n {
let at = ((b * n + g) * n + r) * 3;
let v = sample(&lut, [curve(r as f64 / step), curve(g as f64 / step), curve(b as f64 / step)]);
if let Some(dst) = out.data.get_mut(at..at + 3) {
dst.copy_from_slice(&v);
}
}
}
}
out.check()
}
/// Trilinear lookup of `rgb` (0..1) in a [`LutFile`] (red fastest).
fn sample(l: &LutFile, rgb: [f32; 3]) -> [f32; 3] {
let n = l.size;
let loc = |v: f32| {
let p = v.clamp(0.0, 1.0) * (n - 1) as f32;
let i = (p as usize).min(n.saturating_sub(2));
(i, p - i as f32)
};
let (r0, fr) = loc(rgb[0]);
let (g0, fg) = loc(rgb[1]);
let (b0, fb) = loc(rgb[2]);
let at = |r: usize, g: usize, b: usize, k: usize| l.data.get(((b * n + g) * n + r) * 3 + k).copied().unwrap_or(0.0);
let mut o = [0.0f32; 3];
for (k, ov) in o.iter_mut().enumerate() {
let c = |r, g, b| at(r, g, b, k);
let x00 = c(r0, g0, b0) + (c(r0 + 1, g0, b0) - c(r0, g0, b0)) * fr;
let x10 = c(r0, g0 + 1, b0) + (c(r0 + 1, g0 + 1, b0) - c(r0, g0 + 1, b0)) * fr;
let x01 = c(r0, g0, b0 + 1) + (c(r0 + 1, g0, b0 + 1) - c(r0, g0, b0 + 1)) * fr;
let x11 = c(r0, g0 + 1, b0 + 1) + (c(r0 + 1, g0 + 1, b0 + 1) - c(r0, g0 + 1, b0 + 1)) * fr;
let y0 = x00 + (x10 - x00) * fg;
let y1 = x01 + (x11 - x01) * fg;
*ov = y0 + (y1 - y0) * fb;
}
o
}
/// The code range a `.3dl` node table without a declared output depth was written with: the
/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes
/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as
/// Photoshop 27.11 reads them (#9121).
fn inferred_3dl_scale(max: f64, header: bool) -> f64 {
if max <= 1.0 {
return 1.0;
}
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
depths.iter().copied().find(|&s| max <= s).unwrap_or(max)
}
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
/// (red fastest).
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
// Comments may hold tag-like text, so drop them before looking for tags (#9109).
let mut clean = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("<!--") {
clean.push_str(&rest[..i]);
rest = rest[i + 4..].find("-->").map_or("", |e| &rest[i + 4 + e + 3..]);
}
clean.push_str(rest);
let text = clean.as_str();
let tag = |name: &str| -> Option<String> {
let open = format!("<{name}>");
let start = text.find(&open)? + open.len();
@@ -215,7 +454,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
};
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
let (entries, _) = checked_data_len(size)?;
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if hex.iter().any(|b| !b.is_ascii_hexdigit()) {
return err("invalid hexadecimal <data> in .look");
}
// Each float needs eight hex digits; as_chunks would silently discard a partial value.
if !hex.len().is_multiple_of(8) {
return err("incomplete float in .look <data>");
}
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
@@ -308,6 +554,226 @@ pub fn builtin(id: &str) -> Option<LutFile> {
mod tests {
use super::*;
/// An identity `.3dl` of edge `n` (12-bit nodes, blue fastest) under `shaper`.
fn identity_3dl(shaper: &[u32], n: usize) -> String {
let mut t = shaper.iter().map(u32::to_string).collect::<Vec<_>>().join(" ") + "\n";
let node = |i: usize| (i * 4095 / (n - 1)).to_string();
for r in 0..n {
for g in 0..n {
for b in 0..n {
t += &format!("{} {} {}\n", node(r), node(g), node(b));
}
}
}
t
}
#[test]
fn three_dl_shaper_applies_before_the_table() {
// #8982: Photoshop applies a .3dl's shaper row as a curve ahead of the 3-D table. On grey
// 128 an identity table with the shaper 0 114 455 1023 gives 71 there, and a 17-point
// squared shaper 64 (reported measurements); even ramps leave grey 128 as it is.
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
let bent = parse_3dl(&identity_3dl(&[0, 114, 455, 1023], 4)).unwrap();
assert_eq!(grey(&bent), 71.0);
let squared: Vec<u32> = (0..17).map(|i| (1023.0 * (i as f64 / 16.0).powi(2)).round() as u32).collect();
assert_eq!(grey(&parse_3dl(&identity_3dl(&squared, 17)).unwrap()), 64.0);
let even4 = parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4)).unwrap();
assert_eq!(grey(&even4), 128.0);
assert_eq!(even4, parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).lines().skip(1).collect::<Vec<_>>().join("\n")).unwrap());
let even17: Vec<u32> = (0..17).map(|i| (1023.0 * i as f64 / 16.0).round() as u32).collect();
assert_eq!(grey(&parse_3dl(&identity_3dl(&even17, 17)).unwrap()), 128.0);
// The ends stay put, and the shaper bends each channel on its own.
assert_eq!(sample(&bent, [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]);
let c = sample(&bent, [1.0 / 3.0, 0.0, 2.0 / 3.0]);
assert!((c[0] - 114.0 / 1023.0).abs() < 1e-4 && (c[2] - 455.0 / 1023.0).abs() < 1e-4, "{c:?}");
// A shaper that isn't finite is refused rather than dropped (see
// `three_dl_non_finite_shaper_is_refused`).
assert!(parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).replacen("341", "NaN", 1)).is_err());
}
/// #9170 review: a float shaper (values in 0..1) is held to a float ramp, not to one integer
/// code, so a bent float shaper is applied.
#[test]
fn three_dl_float_shaper_is_applied() {
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
let with = |shaper: &str| {
let table = identity_3dl(&[0, 1, 2, 3], 4);
parse_3dl(&format!("{shaper}\n{}", table.split_once('\n').unwrap().1)).unwrap()
};
// 0.502 lands between 0.1 and 0.3 at 0.506 of the way: 0.2012, so grey 51.
assert_eq!(grey(&with("0 0.1 0.3 1.0")), 51.0);
// An even float ramp, written to three decimals, still changes nothing.
assert_eq!(grey(&with("0 0.333 0.667 1.0")), 128.0);
}
/// #9170 review: a shaper with more points than the table keeps its curve: the table is
/// resampled at the shaper's resolution rather than reading the shaper at its own two nodes.
#[test]
fn three_dl_shaper_finer_than_the_table_keeps_its_curve() {
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
let small = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], 2)).unwrap();
// 0.502 lands between 0 and 512 at 0.506 of the way: 259 / 1023, so grey 65.
assert_eq!(grey(&small), 65.0);
assert_eq!(sample(&small, [1.0, 0.0, 1.0]), [1.0, 0.0, 1.0], "the ends stay put");
}
/// #9170 review: a finite but extreme shaper can't overflow into NaN when it is baked.
#[test]
fn three_dl_extreme_shaper_bakes_only_finite_values() {
let table = identity_3dl(&[0, 1, 2, 3], 2);
let text = format!("0 1e308 -1e308 0\n{}", table.split_once('\n').unwrap().1);
// Refused or baked, but never a table holding NaN.
if let Ok(l) = parse_3dl(&text) {
assert!(l.data.iter().all(|v| v.is_finite()), "baked a non-finite value");
}
}
/// #9170 review (bommerts): the shaper's breakpoints survive a table whose grid doesn't line
/// up with them. The bake grid holds both the table's nodes and the shaper's knots, so on an
/// identity table the curve is exact: `0 0 512 1023` is 0 at ⅓ over 5³ and 17³ tables.
#[test]
fn three_dl_shaper_knots_survive_a_nonaligned_table() {
for n in [5, 17] {
let l = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], n)).unwrap();
let at = |x: f32| sample(&l, [x, 0.0, 0.0])[0];
assert!(at(1.0 / 3.0).abs() < 1e-6, "{n}³: {}", at(1.0 / 3.0));
// The helper's 12-bit nodes are truncated, so the table itself is within a 12-bit step.
assert!((at(2.0 / 3.0) - 512.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: {}", at(2.0 / 3.0));
assert!((at(0.5) - 256.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: halfway between the knots");
}
}
/// A two-value shaper row (a 2³ table's) is applied like a longer one, not dropped; the even
/// two-point ramp changes nothing.
#[test]
fn three_dl_two_point_shaper_is_applied() {
let bent = parse_3dl(&identity_3dl(&[0, 512], 2)).unwrap();
let top = sample(&bent, [1.0, 1.0, 1.0]);
assert!(top.iter().all(|v| (v - 512.0 / 1023.0).abs() < 1e-4), "{top:?}");
assert!((sample(&bent, [0.5, 0.0, 0.0])[0] - 256.0 / 1023.0).abs() < 1e-4);
let even = parse_3dl(&identity_3dl(&[0, 1023], 2)).unwrap();
assert_eq!(
even,
parse_3dl(&identity_3dl(&[0, 1023], 2).lines().skip(1).collect::<Vec<_>>().join(
"
"
))
.unwrap()
);
// A second two-value row is a second shaper, refused like a longer one.
let body = identity_3dl(&[0, 512], 2);
assert!(
parse_3dl(&format!(
"{body}0 512
"
))
.is_err()
);
}
/// #9170 review (bommerts): a shaper row with a token that isn't a number, or a second
/// shaper row, is refused rather than silently dropped.
#[test]
fn three_dl_malformed_shaper_rows_are_refused() {
let table = identity_3dl(&[0, 1, 2, 3], 4);
let body = table.split_once('\n').unwrap().1;
assert!(parse_3dl(&format!("0 341 junk 682 1023\n{body}")).is_err(), "a junk token");
assert!(parse_3dl(&format!("0 114 455 1023\n0 114 455 1023\n{body}")).is_err(), "two shaper rows");
assert!(parse_3dl(&format!("0 114 455 1023\n{body}")).is_ok());
}
/// #9170 review (bommerts): the standard `3DMESH` / `Title lut 12` preamble still reads; only
/// the rows after it are parsed strictly.
#[test]
fn three_dl_mesh_preamble_is_accepted() {
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
let plain = identity_3dl(&[0, 114, 455, 1023], 4);
let headed = parse_3dl(&format!("3DMESH\nTitle lut 12\n{plain}")).unwrap();
assert_eq!(headed, parse_3dl(&plain).unwrap(), "the preamble changes nothing");
assert_eq!(grey(&headed), 71.0, "the bent shaper still applies");
let even = identity_3dl(&[0, 341, 682, 1023], 4);
assert_eq!(grey(&parse_3dl(&format!("3dmesh\nTitle lut 12\n{even}")).unwrap()), 128.0);
assert!(
parse_3dl(&format!("3DMESH\nTitle lut 12\n0 341 junk 682 1023\n{}", even.split_once('\n').unwrap().1)).is_err(),
"still strict after it"
);
}
/// #9170 review: a shaper that isn't finite is refused, not dropped, so a Color Lookup never
/// renders the table alone for a file whose shaper it can't apply.
#[test]
fn three_dl_non_finite_shaper_is_refused() {
let body = identity_3dl(&[0, 1, 2, 3], 4);
let body = body.split_once('\n').unwrap().1;
for shaper in ["0 NaN 682 1023", "0 341 inf 1023", "-inf 341 682 1023"] {
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
}
}
/// #9170 review: a shaper row that starts with `NaN` or `inf` is a shaper, not a header, so
/// it reaches the non-finite refusal instead of being skipped.
#[test]
fn three_dl_shaper_starting_non_finite_is_refused() {
let body = identity_3dl(&[0, 1, 2, 3], 4);
let body = body.split_once('\n').unwrap().1;
for shaper in ["NaN 341 682 1023", "inf 341 682 1023", "-inf 341 682 1023"] {
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
}
}
/// #9170 review: only a line starting with a word is a header. One whose first token looks
/// like a number but isn't (`0oops`) is read strictly and refused, not skipped with its shaper.
#[test]
fn three_dl_malformed_first_token_is_refused() {
let body = identity_3dl(&[0, 1, 2, 3], 4);
let body = body.split_once('\n').unwrap().1;
for shaper in ["0oops 114 455 1023", "-x 114 455 1023", ".5x 114 455 1023", "+ 114 455 1023"] {
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
}
let (first, rest) = body.split_once('\n').unwrap();
assert!(parse_3dl(&format!("0 114 455 1023\n{first}\n1x 0 0\n{rest}")).is_err(), "a table row too");
assert!(parse_3dl(&format!("3DMESH\nTitle lut 12\n0 114 455 1023\n{body}")).is_ok(), "headers still skip");
}
/// #9170 review: a bent shaper whose knots can't all be kept within MAX_SIZE is refused rather
/// than sampled where it may look even (here a 257-point shaper bent only at odd knots, whose
/// 129-node fallback would read the identity).
#[test]
fn three_dl_shaper_too_fine_to_keep_is_refused() {
let shaper: Vec<u32> = (0..257u32).map(|i| (i * 1023 / 256 + if i % 2 == 1 { 40 } else { 0 }).min(1023)).collect();
assert!(parse_3dl(&identity_3dl(&shaper, 2)).is_err());
// As many knots as nodes stays within reach.
let same: Vec<u32> = (0..17u32).map(|i| (1023.0 * (f64::from(i) / 16.0).powi(2)).round() as u32).collect();
assert!(parse_3dl(&identity_3dl(&same, 17)).is_ok());
}
#[test]
fn cube_title_with_hash_round_trips() {
let l = LutFile::from_fn("Look #3", 2, |c| c).unwrap();
assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3");
let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n";
let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
let p = parse_cube(&format!("{t}{rows}")).unwrap();
assert_eq!(p.title, "A #b");
}
#[test]
fn write_cube_keeps_non_default_domain() {
let mut l = LutFile::identity(2).unwrap();
l.domain_min = [0.1; 3];
l.domain_max = [0.9; 3];
let back = parse_cube(&write_cube(&l)).unwrap();
assert_eq!(back.domain_min, [0.1; 3]);
assert_eq!(back.domain_max, [0.9; 3]);
l.domain_min = [0.0, 0.0, 0.5];
l.domain_max = [1.0; 3];
let back = parse_cube(&write_cube(&l)).unwrap();
assert_eq!(back.domain_min, [0.0, 0.0, 0.5]);
assert_eq!(back.domain_max, [1.0; 3]);
assert!(!write_cube(&LutFile::identity(2).unwrap()).contains("DOMAIN"));
}
#[test]
fn cube_domain_is_kept_and_validated() {
let base = "TITLE \"t\"
@@ -403,6 +869,26 @@ DOMAIN_MAX 0.5 1 1
assert!(parse_cube(&text).is_err());
}
#[test]
fn three_dl_without_a_header_reads_low_nodes_as_12_bit() {
let table = |peak: u32| -> String {
let mut text = String::from("0 1023\n");
for i in 0..8 {
let on = |bit: u32| if i & bit != 0 { peak } else { 0 };
text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1)));
}
text
};
for peak in [255, 1023, 4095] {
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
}
// A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a
// cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078).
let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec();
assert_eq!(white, [1.0; 3]);
}
#[test]
fn three_dl_blue_fastest_and_bit_depth() {
let mut text = String::from("0 1023\n");
@@ -418,6 +904,143 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(l.data, LutFile::identity(2).unwrap().data);
}
/// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)`
/// per channel, with a uniform shaper of `size` codes up to 1023.
fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String {
let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n");
if shaper {
for i in 0..size {
if i > 0 {
text.push(' ');
}
text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round()));
}
text.push('\n');
}
for r in 0..size {
for g in 0..size {
for b in 0..size {
let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round());
text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2]));
}
}
}
text
}
#[test]
fn three_dl_mesh_three_point_shaper_is_not_a_node() {
// #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a
// 28th node rejected the whole file.
let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap();
assert_eq!(l.size, 3);
assert_eq!(l.data.len(), 3 * 3 * 3 * 3);
// The inverse identity: node (0,0,0) is full, node (2,2,2) black.
assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6);
let last = &l.data[l.data.len() - 3..];
assert_eq!(last, [0.0, 0.0, 0.0]);
// The same 27 nodes without the shaper row still load (the node-only control).
assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data);
}
#[test]
fn three_dl_mesh_declares_the_output_depth() {
// #9073: identical node codes divide by the declared range, not by the largest value.
let mut text_10 = String::new();
let mut text_12 = String::new();
for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] {
// A 5³ identity scaled to 10-bit codes (max 1023).
*dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n");
for r in 0..5 {
for g in 0..5 {
for b in 0..5 {
dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255));
}
}
}
}
let white_10 = parse_3dl(&text_10).unwrap();
let white_12 = parse_3dl(&text_12).unwrap();
assert_eq!(white_10.size, 5);
let last_10 = &white_10.data[white_10.data.len() - 3..];
let last_12 = &white_12.data[white_12.data.len() - 3..];
assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}");
// 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it.
assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}");
}
#[test]
fn three_dl_mesh_header_mismatch_is_rejected() {
// A Mesh header that disagrees with the row count is refused, not silently re-inferred.
assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err());
// A node beyond the declared range is a wrong declaration: the old inference from the
// largest value keeps the file loading as it did before the header was honoured.
let mut text = String::from("3DMESH\nMesh 1 10\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047));
}
}
}
let l = parse_3dl(&text).unwrap();
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}");
}
#[test]
fn three_dl_mesh_extra_node_cannot_replace_a_captured_shaper() {
for (bits, size) in [(2, 5), (3, 9)] {
let mut text = mesh_3dl(bits, 12, size, 4095.0, true);
let valid = parse_3dl(&text).unwrap();
assert_eq!(valid.size, size);
assert_eq!(valid.data, parse_3dl(&mesh_3dl(bits, 12, size, 4095.0, false)).unwrap().data);
text.push_str("4095 4095 4095\n");
let error = parse_3dl(&text).unwrap_err().to_string();
assert!(error.contains(&format!("expected {} rows", size * size * size)), "{error}");
}
}
#[test]
fn three_dl_malformed_mesh_header_falls_back_to_inference() {
// A `Mesh` line that is not exactly two integer fields is not a declaration: it is
// ignored and the table is inferred as if headerless, rather than recovering numbers
// from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube.
for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] {
let mut text = format!("3DMESH\n{header}\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2, "{header}");
assert_eq!(l.data, LutFile::identity(2).unwrap().data, "{header}");
}
}
#[test]
fn three_dl_mesh_uses_declared_range_for_small_integer_codes() {
// #9073: under a valid `Mesh` declaration the node codes are integers in the declared
// range, so a max value of 1 is 1/4095 of full output — not full output itself.
let mut text = String::from("3DMESH\nMesh 1 12\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
let v = u8::from(r == 0 && g == 0 && b == 0);
text.push_str(&format!("{v} {v} {v}\n"));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 3);
// blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095.
assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]);
assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data);
}
#[test]
fn look_hex_floats() {
let id = LutFile::identity(2).unwrap();
@@ -426,12 +1049,81 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_reads_utf16_with_either_byte_order() {
let id = LutFile::identity(2).unwrap();
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
let xml = format!("<?xml version=\"1.0\" encoding=\"UTF-16\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
let le: Vec<u8> = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect();
let be: Vec<u8> = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect();
assert_eq!(parse("a.look", &le).unwrap().data, id.data);
assert_eq!(parse("a.look", &be).unwrap().data, id.data);
// Sniffed as XML through the byte-order mark too, so the recorded format is the one parsed.
assert_eq!(Format::detect("a.lut", &le), Format::Look);
assert_eq!(parse_as(Format::Look, &be).unwrap().data, id.data);
}
#[test]
fn look_ignores_tags_inside_xml_comments() {
let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() };
let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]).unwrap();
let xml = format!(
"<look><!-- <size>\"3\"</size><data>\"{}\"</data> --><LUT><size>\"2\"</size><data>\"{}\"</data></LUT><!-- unterminated <data>00",
hex(&LutFile::identity(2).unwrap()),
hex(&inverse)
);
assert_eq!(parse("commented.look", xml.as_bytes()).unwrap().data, inverse.data);
}
#[test]
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
let id = LutFile::identity(2).unwrap();
for channels in [3, 4] {
let mut hex = String::new();
for rgb in id.data.as_chunks::<3>().0 {
for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) {
for byte in value.to_le_bytes() {
hex.push_str(&format!("{byte:02x} \t\r\n"));
}
}
}
let xml = format!("<look><size>2</size><data>\"{hex}\"</data></look>");
assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data);
}
}
#[test]
fn look_rejects_partial_floats_and_invalid_hex() {
let id = LutFile::identity(2).unwrap();
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
for suffix in ["0", "00", "0000", "000000", "G", "é"] {
let xml = format!("<look><size>2</size><data>{hex}{suffix}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}");
}
let invalid = hex.replacen('0', "G", 1);
let xml = format!("<look><size>2</size><data>{invalid}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal"));
let xml = format!("<look><size>2</size><data>{hex}00</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float"));
}
#[test]
fn look_3d_size_exponent_overflow_is_rejected() {
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
assert!(parse_look(&text).is_err());
}
#[test]
fn format_detection_matches_parse() {
assert_eq!(Format::detect("a.3DL", b"<look>"), Format::ThreeDl);
assert_eq!(Format::detect("a.Look", b""), Format::Look);
assert_eq!(Format::detect("a.cube", b" <look>"), Format::Look, "XML is sniffed as .look");
assert_eq!(Format::detect("a.txt", b"LUT_3D_SIZE 2"), Format::Cube);
assert_eq!(Format::ThreeDl.extension(), "3dl");
let text = write_cube(&LutFile::identity(2).unwrap());
assert_eq!(parse_as(Format::Cube, text.as_bytes()), parse("a.cube", text.as_bytes()));
}
#[test]
fn malformed_rejected() {
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
+10
View File
@@ -248,6 +248,16 @@ impl Pipeline {
out[..self.outputs].copy_from_slice(&v[..self.outputs]);
}
/// Evaluates the stages after the first one on values already passed through it.
#[inline]
pub(crate) fn eval_after_first(&self, v: &mut [f32; MAX], out: &mut [f32]) {
let mut n = self.inputs;
for (s, edges) in self.stages.iter().zip(&self.edges).skip(1) {
n = s.run(v, n, edges);
}
out[..self.outputs].copy_from_slice(&v[..self.outputs]);
}
/// Removes no-op stages and cancels adjacent inverse pairs (Lab↔XYZ, encode/decode).
pub fn optimize(&mut self) {
let mut out: Vec<Stage> = Vec::with_capacity(self.stages.len());
+140 -39
View File
@@ -443,10 +443,18 @@ impl<'a> Reader<'a> {
Ok(self.u32(off)? as i32 as f64 / 65536.0)
}
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
let r = Reader { b: self.slice(off, 12)? };
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
}
}
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
}
fn sig(s: &[u8; 4]) -> u32 {
u32::from_be_bytes(*s)
}
@@ -736,41 +744,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
}
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
// Offsets come straight from the file: each element is cut out with one checked slice
// and read with local offsets, so no `off + k` can wrap (wasm32, overflow checks off).
let matrix = |off: usize| -> Result<Stage, CmsError> {
let e = Reader { b: r.slice(off, 48)? };
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = e.s15f16(k * 4)?;
}
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
};
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
let h = Reader { b: r.slice(off, 20)? };
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = h.u8(k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = h.u8(16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
charge_clut_values(total)?;
let data: Vec<f32> = match prec {
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)?))
};
let matrix = |off: usize| lut_ab_matrix(&r, off);
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
let mut stages = Vec::new();
if ty == b"mAB " {
// A curves → CLUT → M curves → matrix → B curves.
@@ -817,6 +792,46 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
}
}
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
let e = Reader { b: r.slice(off, 48)? };
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = e.s15f16(k * 4)?;
}
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
}
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
// Grid points (16 bytes), precision and padding; the samples follow.
let head = Reader { b: r.slice(off, 20)? };
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = head.u8(k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = head.u8(16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
charge_clut_values(total)?;
let samples = extent(off, 20, 1)?;
let data: Vec<f32> = match prec {
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)?))
}
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
fn parse_text(d: &[u8]) -> String {
let r = Reader { b: d };
@@ -888,7 +903,92 @@ mod tests {
d.extend_from_slice(&2u32.to_be_bytes());
d.extend_from_slice(&(u32::MAX - 20).to_be_bytes());
d.extend_from_slice(&[0u8; 12]);
assert!(parse_text(&d).is_empty());
assert!(parse_text(&d).is_empty())
}
fn be(v: u32) -> [u8; 4] {
v.to_be_bytes()
}
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
#[test]
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
let bytes = [0u8; 64];
let r = Reader { b: &bytes };
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
}
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
assert_eq!(extent(16, 3, 12), Ok(52));
}
#[test]
fn a_curve_count_is_checked_before_its_length_is_computed() {
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
// Controls: the identity and a two-point table still parse with their lengths.
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(0));
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(2));
d.extend([0, 0, 0xFF, 0xFF]);
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
}
#[test]
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
// offsets in turn point at the end of a 32-bit file.
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
let mut d = b"mAB \0\0\0\0".to_vec();
d.extend([3, 3, 0, 0]);
for v in [b, matrix, m, clut, a] {
d.extend(be(v));
}
for _ in 0..3 {
d.extend(b"curv\0\0\0\0");
d.extend(be(0));
}
d
};
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
let top = u32::MAX - 8;
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
}
#[test]
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
let record = |lang: &[u8; 2], len: u32, off: u32| {
let mut r = lang.to_vec();
r.extend(b"US");
r.extend(be(len));
r.extend(be(off));
r
};
// Two records of the standard 12 bytes: the English one wins.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(2));
d.extend(be(12));
d.extend(record(b"de", 4, 40));
d.extend(record(b"en", 4, 44));
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
assert_eq!(parse_text(&d), "EN");
// A record size of u32::MAX: the second record lies past any file, so the first is used.
d[12..16].copy_from_slice(&be(u32::MAX));
assert_eq!(parse_text(&d), "DE");
// No records at all.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
d.extend(be(u32::MAX));
assert_eq!(parse_text(&d), "");
}
#[test]
@@ -911,13 +1011,14 @@ mod tests {
}
/// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a
/// crafted profile can have another's. Setting it stands in for finding one.
/// crafted profile can have another's (#8613). Setting it stands in for finding one.
fn with_hash_of(p: &Profile, other: &Profile) -> Profile {
let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() };
assert_eq!(p.content_hash(), other.content_hash());
p
}
/// #8613: a profile that shares sRGB's hash is not taken for sRGB.
#[test]
fn a_hash_collision_is_not_the_same_colours() {
let srgb = Builtin::Srgb.profile();
@@ -930,6 +1031,7 @@ mod tests {
assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile()));
}
/// #8613: the transform cache does not hand a colliding profile the other's transform.
#[test]
fn a_hash_collision_does_not_get_the_cached_transform() {
use crate::transform::{Transform, TransformOptions, cached};
@@ -950,7 +1052,6 @@ mod tests {
assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr);
}
}
#[cfg(test)]
mod parse_budget_tests {
use super::*;
+1 -1
View File
@@ -33,7 +33,7 @@ use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent, invalid_arg};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
+142 -6
View File
@@ -2,14 +2,14 @@
//! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use crate::clut::{Clut, MAX_CHANNELS};
use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent, check_len, invalid_arg};
use crate::{CmsError, GpuMatrixShaper, Intent, check_len, invalid_arg};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,9 +39,11 @@ impl Default for TransformOptions {
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
pub const GRID_3D: usize = 33;
pub const GRID_4D: usize = 17;
const TABLE_1D: usize = 4096;
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
const OUT_TABLE: usize = 4096;
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
// The blocked matrix/TRC path wraps table indices with a mask instead of a bounds check.
const _: () = assert!(OUT_TABLE.is_power_of_two());
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
@@ -91,6 +93,8 @@ pub struct Transform {
post: Option<Post>,
/// Pre-located grid coordinates per 8-bit code: (offset contribution, fraction) per input.
grid8: Vec<[(u32, f32); 256]>,
/// For an exact core: the first stage's curves at every 8-bit code.
exact8: Vec<[f32; 256]>,
white: Option<White>,
}
@@ -202,7 +206,13 @@ impl Transform {
.collect(),
_ => Vec::new(),
};
Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8, white: None })
let exact8 = match (&core, pipeline.stages().first()) {
(Core::Exact, Some(Stage::Curves(cs))) if cs.len() == inputs && inputs <= 4 => {
cs.iter().map(|c| std::array::from_fn(|i| c.eval(i as f32 / 255.0))).collect()
}
_ => Vec::new(),
};
Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8, exact8, white: None })
}
/// White preservation: the source's device white maps to exactly the destination's device
@@ -279,6 +289,31 @@ impl Transform {
&self.pipeline
}
/// A separable RGB transform for accurate GPU display conversion, when available.
///
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
/// can keep their existing display-LUT fallback. This is an SDR representation; see
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
if self.inputs != 3 || self.outputs != 3 {
return None;
}
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
if let Some((nr, nc, coefficients, offsets)) = matrix {
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
return None;
}
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
let [r, g, b] = coefficients;
*row = [*r, *g, *b, *offset];
}
}
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
}
/// Exact evaluation of one colour: `input` holds at least `inputs` normalised values, `out`
/// receives `outputs` values.
pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
@@ -450,6 +485,10 @@ impl Transform {
}
}
}
Core::Shaper { curves8, matrix: Some((3, 3, m, off)), .. } if self.shaper3_ok(curves8.len(), m.len(), off.len()) => {
let load = |sp: &[u8]| [curves8[0][sp[0] as usize], curves8[1][sp[1] as usize], curves8[2][sp[2] as usize]];
self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, q);
}
Core::Shaper { curves8, matrix, .. } => {
let mut v = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
@@ -466,6 +505,27 @@ impl Transform {
}
}
}
Core::Exact if !self.exact8.is_empty() => {
let mut inp = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
let mut v = [0.0f32; 16];
for k in 0..self.inputs {
inp[k] = sp[k] as f32 / 255.0;
v[k] = self.exact8[k][sp[k] as usize];
}
// Device white stays exact, as on the per-pixel exact path.
match self.white_for(&inp) {
Some(w) => out[..self.outputs].copy_from_slice(w),
None => self.pipeline.eval_after_first(&mut v, &mut out),
}
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
_ => {
let mut inp = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
@@ -484,6 +544,74 @@ impl Transform {
}
}
fn shaper3_ok(&self, curves: usize, m: usize, off: usize) -> bool {
self.inputs == 3
&& self.outputs == 3
&& curves == 3
&& m == 9
&& off == 3
&& self.post.as_ref().is_some_and(|p| p.tables.len() == 3 && p.tables.iter().all(|t| t.len() == OUT_TABLE))
}
/// Matrix/TRC transform of whole pixels in blocks: the matrix and the output-table index
/// maths run over a block of pixels at a time (same operations, same order, per pixel).
#[allow(clippy::too_many_arguments)]
fn shaper3_blocks<S: Copy>(
&self,
s: &[S],
ss: usize,
d: &mut [S],
ds: usize,
extra: usize,
m: &[f32],
off: &[f32],
load: impl Fn(&[S]) -> [f32; 3],
store: impl Fn(f32) -> S,
) {
const B: usize = 64;
let Some(post) = self.post.as_ref() else { return };
let tabs = [&post.tables[0][..OUT_TABLE], &post.tables[1][..OUT_TABLE], &post.tables[2][..OUT_TABLE]];
let m: [f32; 9] = std::array::from_fn(|i| m[i]);
let off: [f32; 3] = std::array::from_fn(|i| off[i]);
let n_px = (s.len() / ss).min(d.len() / ds);
let mut x = [[0.0f32; B]; 3];
let mut start = 0;
while start < n_px {
let n = B.min(n_px - start);
let sb = &s[start * ss..(start + n) * ss];
let db = &mut d[start * ds..(start + n) * ds];
for (j, sp) in sb.chunks_exact(ss).enumerate() {
let [a, b, c] = load(sp);
for r in 0..3 {
let mut acc = off[r];
acc += m[r * 3] * a;
acc += m[r * 3 + 1] * b;
acc += m[r * 3 + 2] * c;
x[r][j] = acc;
}
}
for (ch, t) in tabs.iter().enumerate() {
let mut u = [0.0f32; B];
let mut ix = [0i32; B];
for j in 0..B {
let v = x[ch][j].clamp(0.0, 1.0).sqrt().sqrt() * (OUT_TABLE - 1) as f32;
let i = (v as i32).min(OUT_TABLE as i32 - 2);
ix[j] = i;
u[j] = v - i as f32;
}
for (j, dp) in db.chunks_exact_mut(ds).enumerate() {
let i = ix[j] as usize & (OUT_TABLE - 1);
let (v0, v1) = (t[i], t[(i + 1) & (OUT_TABLE - 1)]);
dp[ch] = store(v0 + (v1 - v0) * u[j]);
}
}
for (sp, dp) in sb.chunks_exact(ss).zip(db.chunks_exact_mut(ds)) {
dp[3..3 + extra].copy_from_slice(&sp[3..3 + extra]);
}
start += n;
}
}
/// Converts 16-bit pixels (see [`Transform::convert_f32`] for the layout).
pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> {
self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?;
@@ -493,6 +621,14 @@ impl Transform {
}
fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) {
if let Core::Shaper { curves, matrix: Some((3, 3, m, off)), .. } = &self.core
&& self.shaper3_ok(curves.len(), m.len(), off.len())
{
let load = |sp: &[u16]| {
[table(&curves[0], sp[0] as f32 / 65535.0), table(&curves[1], sp[1] as f32 / 65535.0), table(&curves[2], sp[2] as f32 / 65535.0)]
};
return self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16);
}
let mut inp = [0.0f32; 16];
let mut out = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
@@ -828,7 +964,7 @@ pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Ar
None => {}
}
let t = Arc::new(Transform::with_options(src, dst, opts)?);
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
let mut c = cache.lock().unwrap_or_else(PoisonError::into_inner);
if c.len() > 64 {
c.clear();
}
+90
View File
@@ -991,3 +991,93 @@ fn white_is_exact_on_every_path() {
assert!(o[2] < 0.99, "{cmyk:?}: absolute paper {o:?}");
}
}
/// The blocked matrix/TRC path (64 pixels at a time) must match the per-pixel evaluation for
/// every pixel count around the block size, with and without extra channels.
#[test]
fn matrix_trc_blocks_match_per_pixel_eval() {
let pairs = [(Builtin::Srgb, Builtin::DisplayP3), (Builtin::ProPhotoCompat, Builtin::Srgb), (Builtin::AdobeRgbCompat, Builtin::Rec2020)];
for (a, b) in pairs {
let t = Transform::new(a.profile(), b.profile(), Intent::RelativeColorimetric, true).unwrap();
for &(ss, ds, copy) in &[(3usize, 3usize, false), (4, 4, true), (5, 4, true)] {
let max = 20011;
let seed = |i: usize| (i.wrapping_mul(2654435761) >> 7) as u32;
let src16: Vec<u16> = (0..max * ss).map(|i| (seed(i) >> 3) as u16).collect();
let src8: Vec<u8> = (0..max * ss).map(|i| (seed(i) >> 11) as u8).collect();
let mut full8 = vec![0u8; max * ds];
t.convert_u8(&src8, ss, &mut full8, ds, copy).unwrap();
for px in [1usize, 2, 63, 64, 65, 127, 128, 129, 1000] {
let mut o16 = vec![0u16; px * ds];
t.convert_u16(&src16[..px * ss], ss, &mut o16, ds, copy).unwrap();
let mut o8 = vec![0u8; px * ds];
t.convert_u8(&src8[..px * ss], ss, &mut o8, ds, copy).unwrap();
assert_eq!(o8[..], full8[..px * ds], "u8 prefix of a longer buffer, {px} px");
for p in 0..px {
let inp: Vec<f32> = (0..3).map(|k| src16[p * ss + k] as f32 / 65535.0).collect();
let mut want = [0.0f32; 3];
t.eval_fast(&inp, &mut want).unwrap();
for k in 0..3 {
assert_eq!(o16[p * ds + k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 px {p} ch {k} of {px}");
}
if copy && ds > 3 {
assert_eq!(o16[p * ds + 3], src16[p * ss + 3]);
assert_eq!(o8[p * ds + 3], src8[p * ss + 3]);
}
}
}
}
}
}
/// 8-bit and 16-bit conversion through an analytic pipeline (no lookup table, here RGB to Lab)
/// must equal the exact per-pixel evaluation. The 8-bit path tabulates the first stage's
/// curves per code; `curves_are_tables` selects whether those curves are parametric (their
/// `eval` is analytic) or `curv` tables (their `eval` interpolates inside the same loop).
fn check_exact_core(src_profile: &Profile, curves_are_tables: bool) {
let trc = src_profile.trc.as_ref().expect("matrix/TRC source");
assert!(trc.iter().all(|c| matches!(c, craft_cms::Curve::Table(_)) == curves_are_tables), "TRC kind");
let t = Transform::new(src_profile, lab(), Intent::RelativeColorimetric, true).unwrap();
let px = 256usize * 256;
let src: Vec<u8> = (0..px as u32).flat_map(|i| [(i & 255) as u8, (i >> 8) as u8, (i.wrapping_mul(2654435761) >> 13) as u8, 200]).collect();
let mut out = vec![0u8; px * 4];
t.convert_u8(&src, 4, &mut out, 4, true).unwrap();
for (sp, dp) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) {
let inp = [sp[0] as f32 / 255.0, sp[1] as f32 / 255.0, sp[2] as f32 / 255.0];
let mut want = [0.0f32; 3];
t.eval(&inp, &mut want).unwrap();
for k in 0..3 {
assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 255.0 + 0.5) as u8, "u8 {sp:?} ch {k}");
}
assert_eq!(dp[3], 200);
}
// 16-bit input stays off the 8-bit table and must agree with the untabulated evaluation.
let src16: Vec<u16> = (0..px * 4).map(|i| if i % 4 == 3 { 51400 } else { (i.wrapping_mul(2654435761) >> 9) as u16 }).collect();
let mut out16 = vec![0u16; px * 4];
t.convert_u16(&src16, 4, &mut out16, 4, true).unwrap();
for (sp, dp) in src16.as_chunks::<4>().0.iter().zip(out16.as_chunks::<4>().0) {
let inp = [sp[0] as f32 / 65535.0, sp[1] as f32 / 65535.0, sp[2] as f32 / 65535.0];
let mut want = [0.0f32; 3];
t.eval(&inp, &mut want).unwrap();
for k in 0..3 {
assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 {sp:?} ch {k}");
}
assert_eq!(dp[3], 51400);
}
}
#[test]
fn exact_core_u8_matches_exact_eval() {
check_exact_core(srgb(), false);
}
/// Same, with `curv` table TRCs: the first-stage table is built from `Curve::eval`'s table
/// branch and must still be applied to its own channel.
#[test]
fn exact_core_u8_matches_exact_eval_with_table_trc() {
// Distinct tables per channel, so a first stage applied to the wrong channel shows up.
let mut p = srgb_as_v2_tables();
let table = |g: f64| craft_cms::Curve::Table((0..1024).map(|i| ((i as f64 / 1023.0).powf(g) * 65535.0).round() as f32 / 65535.0).collect());
p.trc = Some([table(1.8), table(2.2), table(2.6)]);
let table_trc = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
check_exact_core(&table_trc, true);
}
+201
View File
@@ -0,0 +1,201 @@
//! Independent ICC matrix/TRC reference for display shadows.
//!
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
//! directly in f64, without Transform, Curve evaluation, or craft_cms::math:
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
use craft_cms::pipeline::{Pipeline, Stage};
use craft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
const GAMMA: f64 = 2.2;
const SOURCE_ENTRIES: usize = 1024;
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
fn fixed16(x: f64) -> f64 {
(x * 65536.0).round() / 65536.0
}
fn srgb_sample(i: usize) -> f64 {
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
(linear * 65535.0).round() / 65535.0
}
fn synthetic_profiles() -> (Profile, Profile) {
let mut source = Builtin::Srgb.profile().clone();
source.description = "Synthetic sampled sRGB for display regression".into();
source.matrix = Some(SOURCE_MATRIX);
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
let source = source.with_encoded_bytes();
let mut monitor = Builtin::Srgb.profile().clone();
monitor.description = "Synthetic gamma 2.2 display for regression".into();
monitor.matrix = Some(MONITOR_MATRIX);
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
let monitor = monitor.with_encoded_bytes();
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
}
fn synthetic_transform(bpc: bool) -> Transform {
let (source, monitor) = synthetic_profiles();
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
}
fn reference(rgb: [f32; 3]) -> [f64; 3] {
let linear = rgb.map(|x| {
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
let i = (p as usize).min(SOURCE_ENTRIES - 2);
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
});
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
// matrix inverse/multiplication implementation.
let mut a: [[f64; 4]; 3] =
std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
for pivot in 0..3 {
let divisor = a[pivot][pivot];
for value in &mut a[pivot][pivot..] {
*value /= divisor;
}
let normalized = a[pivot];
for (r, row) in a.iter_mut().enumerate() {
if r != pivot {
let factor = row[pivot];
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
*value -= factor * coefficient;
}
}
}
}
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
}
/// CPU interpretation of the public GPU data layout; expected colours come only from
/// `reference`, so this tests the sampled representation, not Transform against itself.
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
let i = (coordinate as usize).min(table.len() - 2);
let f = coordinate - i as f32;
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
}
let linear: [f32; 3] =
std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
let rows = gpu.matrix_rows();
std::array::from_fn(|c| {
let row = rows[c];
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
})
}
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
let actual = sample_gpu(gpu, rgb);
let expected = reference(rgb);
for c in 0..3 {
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
}
}
#[test]
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
for bpc in [false, true] {
let transform = synthetic_transform(bpc);
let gpu = transform.gpu_matrix_shaper().unwrap();
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
for v in 0..=255 {
check_reference(&gpu, [v as f32 / 255.0; 3]);
}
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
for r in 0..=16 {
for g in 0..=16 {
for b in 0..=16 {
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
}
}
}
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
check_reference(&gpu, rgb);
}
}
}
#[test]
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
let transform = synthetic_transform(false);
let gpu = transform.gpu_matrix_shaper().unwrap();
let coarse = Lut3d::from_transform(&transform, 33).unwrap();
let rgb = [2.0 / 255.0; 3];
let expected = reference(rgb);
let old = coarse.sample(rgb);
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
// guards the cause of the original bug rather than a particular observed screenshot.
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
check_reference(&gpu, rgb);
}
#[test]
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
let transform = synthetic_transform(false);
let gpu = transform.gpu_matrix_shaper().unwrap();
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
check_reference(&gpu, [v as f32 / 255.0; 3]);
}
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
check_reference(&gpu, [v as f32 / 65535.0; 3]);
}
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
check_reference(&gpu, rgb);
}
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
check_reference(&gpu, [1.0; 3]);
}
#[test]
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()).unwrap(), TransformOptions::default()).unwrap().gpu_matrix_shaper().unwrap();
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
}
}
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
let transform =
Transform::from_pipeline(Pipeline::new(3, vec![matrix]).unwrap(), TransformOptions::default()).unwrap().gpu_matrix_shaper().unwrap();
let input = [0.1, 0.2, 0.3];
let output = sample_gpu(&transform, input);
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
assert!((actual - expected).abs() < 1e-6, "{output:?}");
}
}
#[test]
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input)).unwrap();
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]).unwrap(), TransformOptions::default()).unwrap();
assert!(clut.gpu_matrix_shaper().is_none());
let srgb = Builtin::Srgb.profile();
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
assert!(lab.gpu_matrix_shaper().is_none());
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
assert!(gray.gpu_matrix_shaper().is_none());
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
assert!(proof.gpu_matrix_shaper().is_none());
}
#[test]
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
// A matrix whose storage does not match its shape is refused when the pipeline is built.
let Ok(pipeline) = Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]) else { continue };
let transform = Transform::from_pipeline(pipeline, TransformOptions::default()).unwrap();
assert!(transform.gpu_matrix_shaper().is_none());
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ fn hex(id: [u8; 16]) -> String {
#[test]
fn the_id_matches_an_independent_md5() {
// Computed outside Rust: MD5 of the shipped file with bytes 44–47, 64–67 and 84–99 zeroed.
assert_eq!(hex(profile_id(builtin::COATED_CMYK_ICC).unwrap()), "c6eeebc63fac1fa8be528bcac92d67a9");
assert_eq!(hex(profile_id(builtin::COATED_CMYK_ICC).unwrap()), "ab1397490b9355815dea5668de6eb073");
}
#[test]