Files
craft-libs/crates/cms/src/lutfile.rs
Dylan M. Taylor 628a235ae5 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
2026-11-11 18:37:23 -05:00

1167 lines
53 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Creative 3D LUT files for Image › Adjustments › Color Lookup: `.cube` (Adobe/Resolve), `.3dl`
//! (Autodesk Lustre / Flame) and `.look` (SpeedGrade XML), plus a few generated (CC0) looks.
//!
//! Every table is normalised to [`LutFile`]: `size`³ RGB triplets in 0..=1, **red varying
//! fastest** (`((b·size + g)·size + r)·3`). Formats follow their public descriptions: the Adobe
//! "Cube LUT Specification 1.0", the Lustre 3DL layout as documented by OpenColorIO, and the
//! SpeedGrade `.look` XML (hex-encoded little-endian float32 RGB triplets).
/// A parsed 3D LUT.
#[derive(Clone, Debug, PartialEq)]
pub struct LutFile {
pub title: String,
pub size: usize,
/// `size`³ × 3 values, red fastest.
pub data: Vec<f32>,
/// The input domain the table expects (`.cube` `DOMAIN_MIN`/`DOMAIN_MAX`,
/// `LUT_3D_INPUT_RANGE`): an input `c` samples the table at `(c - min) / (max - min)`,
/// saturating outside. `[0, 1]` is the identity default; applying a table with any other
/// domain is rejected upstream until Color Lookup carries it through.
pub domain_min: [f32; 3],
pub domain_max: [f32; 3],
}
/// Largest edge length accepted (64³ is Photoshop's practical maximum; 129 leaves headroom while
/// keeping the GPU table within texture limits).
pub const MAX_SIZE: usize = 129;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LutError(pub String);
impl std::fmt::Display for LutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for LutError {}
fn err<T>(msg: impl Into<String>) -> Result<T, LutError> {
Err(LutError(msg.into()))
}
fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
if !(2..=MAX_SIZE).contains(&size) {
return err(format!("unsupported LUT size {size}"));
}
let entries = size.checked_pow(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data_len = entries.checked_mul(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
Ok((entries, data_len))
}
impl LutFile {
/// The identity table of edge `size` (2 to [`MAX_SIZE`]).
pub fn identity(size: usize) -> Result<Self, LutError> {
Self::from_fn("Identity", size, |c| c)
}
/// Samples `f` on an `size`³ grid, `size` from 2 to [`MAX_SIZE`].
pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Result<Self, LutError> {
let (_, data_len) = checked_data_len(size)?;
let m = (size - 1) as f32;
let mut data = Vec::with_capacity(data_len);
for b in 0..size {
for g in 0..size {
for r in 0..size {
let o = f([r as f32 / m, g as f32 / m, b as f32 / m]);
data.extend(o.map(|v| v.clamp(0.0, 1.0)));
}
}
}
Ok(LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] })
}
/// Whether the table expects plain 0..1 inputs (the common case; Photoshop's default).
pub fn domain_is_default(&self) -> bool {
self.domain_min == [0.0, 0.0, 0.0] && self.domain_max == [1.0, 1.0, 1.0]
}
fn check(self) -> Result<Self, LutError> {
let (entries, data_len) = checked_data_len(self.size)?;
if self.data.len() != data_len {
return err(format!("expected {entries} entries, found {}", self.data.len() / 3));
}
if self.data.iter().any(|v| !v.is_finite()) {
return err("non-finite LUT value");
}
Ok(self)
}
}
/// 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> {
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),
}
}
/// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table.
pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
let mut title = String::new();
let (mut size3, mut size1) = (0usize, 0usize);
let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]);
let mut rows: Vec<f32> = Vec::new();
for line in text.lines() {
// 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;
}
let mut it = line.split_whitespace();
let key = it.next().unwrap_or("");
let nums = |it: std::str::SplitWhitespace<'_>| -> Result<[f32; 3], LutError> {
let v: Vec<f32> = it.filter_map(|s| s.parse().ok()).collect();
if v.len() == 3 { Ok([v[0], v[1], v[2]]) } else { err(format!("bad line `{line}`")) }
};
match key {
"TITLE" => title = line[5..].trim().trim_matches('"').to_string(),
"LUT_3D_SIZE" => size3 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))?,
"LUT_1D_SIZE" => size1 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))?,
"DOMAIN_MIN" => dmin = nums(it)?,
"DOMAIN_MAX" => dmax = nums(it)?,
"LUT_3D_INPUT_RANGE" | "LUT_1D_INPUT_RANGE" => {
let v: Vec<f32> = it.filter_map(|s| s.parse().ok()).collect();
if v.len() == 2 {
dmin = [v[0]; 3];
dmax = [v[1]; 3];
}
}
k if k.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') => {
let mut v = vec![k.parse::<f32>().map_err(|_| LutError(format!("bad number in `{line}`")))?];
v.extend(it.filter_map(|s| s.parse::<f32>().ok()));
if v.len() != 3 {
return err(format!("bad data line `{line}`"));
}
rows.extend(v);
}
// Unknown keywords (e.g. Resolve's LUT_IN_VIDEO_RANGE) are ignored, as the spec allows.
_ => {}
}
}
let domain_ok = (0..3).all(|k| dmin[k] < dmax[k] && dmin[k].is_finite() && dmax[k].is_finite());
if (size3 > 0 || size1 >= 2) && !domain_ok {
return err(format!("DOMAIN_MIN {:?} must lie below DOMAIN_MAX {:?}", dmin, dmax));
}
if size3 > 0 {
return LutFile { title, size: size3, data: rows, domain_min: dmin, domain_max: dmax }.check();
}
if size1 >= 2 {
let expected_rows = size1.checked_mul(3).ok_or_else(|| LutError(format!("unsupported 1D LUT size {size1}")))?;
if rows.len() != expected_rows {
return err("no LUT_3D_SIZE in .cube file");
}
// The 1D table is resampled onto a 0..1 cube anyway, so its input domain
// (`DOMAIN_MIN`/`MAX`, `LUT_1D_INPUT_RANGE`) is baked into the resample: an input `v`
// reads the table at `(v - min) / (max - min)`, saturating outside.
let curve = |ch: usize, v: f32| {
let t = (v - dmin[ch]) / (dmax[ch] - dmin[ch]);
let x = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 } * (size1 - 1) as f32;
let i = (x.floor() as usize).min(size1 - 2);
let f = x - i as f32;
rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f
};
let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])])?;
l.title = title;
return l.check();
}
err("no LUT_3D_SIZE in .cube file")
}
/// 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();
// 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;
}
// 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);
}
}
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 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;
}
}
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();
let end = text[start..].find(&format!("</{name}>"))? + start;
Some(text[start..end].trim().trim_matches('"').trim().to_string())
};
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(|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();
let rgba_entries = entries.checked_mul(4).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data = if floats.len() == rgba_entries { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
LutFile { title: tag("title").unwrap_or_default(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
}
/// Writes a `.cube` file (what Photoshop embeds in a Color Lookup layer).
pub fn write_cube(l: &LutFile) -> String {
let mut s = String::new();
if !l.title.is_empty() {
s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'")));
}
// The domain is what every row means: a table read with a non-default one must not be
// written back as if it expected inputs in 0..1. `parse_cube` accepts these anywhere.
if !l.domain_is_default() {
s.push_str(&format!("DOMAIN_MIN {} {} {}\n", l.domain_min[0], l.domain_min[1], l.domain_min[2]));
s.push_str(&format!("DOMAIN_MAX {} {} {}\n", l.domain_max[0], l.domain_max[1], l.domain_max[2]));
}
s.push_str(&format!("LUT_3D_SIZE {}\n", l.size));
for c in l.data.as_chunks::<3>().0 {
s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2]));
}
s
}
fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smooth(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with this crate (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
("tealOrange", "Teal & Orange"),
("bleachBypass", "Bleach Bypass"),
("fadedFilm", "Faded Film"),
("dayForNight", "Day for Night"),
("monoContrast", "Mono High Contrast"),
("crossProcess", "Cross Process"),
];
/// A built-in look by id (33³), or None.
pub fn builtin(id: &str) -> Option<LutFile> {
let label = BUILTIN.iter().find(|b| b.0 == id)?.1;
let f: Box<dyn Fn([f32; 3]) -> [f32; 3]> = match id {
"warm" => Box::new(|c: [f32; 3]| [c[0] * 0.92 + 0.08, c[1] * 0.97 + 0.02, c[2] * 0.85]),
"cool" => Box::new(|c: [f32; 3]| [c[0] * 0.86, c[1] * 0.97 + 0.01, c[2] * 0.9 + 0.1]),
"tealOrange" => Box::new(|c: [f32; 3]| {
// Shadows toward teal, highlights toward orange, skin-ish midtones kept.
let l = luma(c);
let t = smooth(l);
let teal = [0.0, 0.5, 0.55];
let orange = [1.0, 0.62, 0.3];
std::array::from_fn(|k| {
let tint = teal[k] * (1.0 - t) + orange[k] * t;
c[k] * 0.75 + (tint * l * 1.1).min(1.0) * 0.25 + (smooth(c[k]) - c[k]) * 0.3
})
}),
"bleachBypass" => Box::new(|c: [f32; 3]| {
let l = luma(c);
let s = smooth(l);
std::array::from_fn(|k| {
let desat = c[k] * 0.45 + l * 0.55;
desat * 0.5 + s * 0.5
})
}),
"fadedFilm" => Box::new(|c: [f32; 3]| {
let l = luma(c);
let tint = [1.0, 0.96, 0.88];
std::array::from_fn(|k| (0.08 + (c[k] * 0.8 + l * 0.2) * 0.84) * tint[k] + 0.02)
}),
"dayForNight" => Box::new(|c: [f32; 3]| {
let l = luma(c);
[l * 0.35, l * 0.45 + c[1] * 0.05, l * 0.65 + c[2] * 0.15]
}),
"monoContrast" => Box::new(|c: [f32; 3]| [smooth(smooth(luma(c))); 3]),
_ /* crossProcess */ => Box::new(|c: [f32; 3]| [smooth(c[0]) * 1.05, c[1] * 1.1 - 0.03, c[2] * 0.7 + 0.15]),
};
LutFile::from_fn(label, 33, f).ok()
}
#[cfg(test)]
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\"
LUT_3D_SIZE 2
";
let rows = "0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1
";
// The default domain parses as default.
let d = parse_cube(&format!("{base}{rows}")).expect("default domain");
assert!(d.domain_is_default());
// DOMAIN_MIN/MAX are kept for the caller to act on.
let d = parse_cube(&format!(
"DOMAIN_MIN 0.1 0.2 0.3
DOMAIN_MAX 0.9 0.8 0.7
{base}{rows}"
))
.expect("custom domain");
assert_eq!(d.domain_min, [0.1, 0.2, 0.3]);
assert_eq!(d.domain_max, [0.9, 0.8, 0.7]);
assert!(!d.domain_is_default());
// A degenerate domain is a parse error, not a division by zero later.
let e = parse_cube(&format!(
"DOMAIN_MIN 0.5 0.5 0.5
DOMAIN_MAX 0.5 1 1
{base}{rows}"
))
.unwrap_err();
assert!(e.0.contains("below DOMAIN_MAX"), "{e}");
}
#[test]
fn cube_1d_domain_is_baked_into_the_resample() {
// An identity ramp over the domain 0.25..0.75: 0.25 maps to 0, 0.5 to 0.5, 0.75 to 1.
let table = "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 0.25 0.75\n0 0 0\n1 1 1\n";
let l = parse_cube(table).expect("1D with input range");
assert!(l.domain_is_default(), "the domain is baked in, so the cube is plain 0..1");
let at = |r: usize| l.data[r * 3];
// 33-point grid, red fastest: entry r on the red axis is input r / 32.
assert!(at(0).abs() < 1e-6, "below the domain saturates to 0");
assert!((at(16) - 0.5).abs() < 1e-5, "0.5 is the middle of 0.25..0.75: {}", at(16));
assert!((at(8) - 0.0).abs() < 1e-5, "0.25 maps to 0: {}", at(8));
assert!((at(24) - 1.0).abs() < 1e-5, "0.75 maps to 1: {}", at(24));
assert!((at(32) - 1.0).abs() < 1e-6, "above the domain saturates to 1");
// Per-channel DOMAIN_MIN/MAX work the same way.
let l = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0.5\nDOMAIN_MAX 1 1 1\n0 0 0\n1 1 1\n").expect("1D domain");
let blue_mid = l.data[(16 * 33 * 33) * 3 + 2];
assert!(blue_mid.abs() < 1e-5, "blue 0.5 is the bottom of its domain: {blue_mid}");
// A degenerate 1D domain is rejected like the 3D one.
let e = parse_cube("LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 1 1\n0 0 0\n1 1 1\n").unwrap_err();
assert!(e.0.contains("below DOMAIN_MAX"), "{e}");
}
#[test]
fn cube_roundtrip_and_identity() {
let id = LutFile::identity(5).unwrap();
let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() });
let back = parse_cube(&text).unwrap();
assert_eq!(back.size, 5);
assert_eq!(back.title, "Id");
for (a, b) in back.data.iter().zip(&id.data) {
assert!((a - b).abs() < 1e-5);
}
// Red varies fastest: entry 1 is (0.25, 0, 0).
assert_eq!(&back.data[3..6], &[0.25, 0.0, 0.0]);
}
#[test]
fn cube_1d_expands() {
let text = "LUT_1D_SIZE 2\n0 0 0\n0.5 1 1\n";
let l = parse_cube(text).unwrap();
assert_eq!(l.size, 33);
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 0.5).abs() < 1e-6 && (last[1] - 1.0).abs() < 1e-6);
let mut larger = String::from("LUT_1D_SIZE 256\n");
for i in 0..256 {
let value = i as f32 / 255.0;
larger.push_str(&format!("{value} {value} {value}\n"));
}
assert_eq!(parse_cube(&larger).unwrap().size, 33);
}
#[test]
fn cube_1d_size_multiplication_overflow_is_rejected() {
let text = format!("LUT_1D_SIZE {}\n", usize::MAX);
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");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text.push_str(&format!("{} {} {}\n", r * 4095, g * 4095, b * 4095));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2);
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();
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\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
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());
assert!(parse_cube("garbage").is_err());
assert!(parse_cube("LUT_3D_SIZE 99999\n").is_err());
assert!(parse_3dl("1 2 3\n4 5 6\n").is_err());
assert!(parse_look("<look></look>").is_err());
}
#[test]
fn builtins_exist_and_are_in_range() {
for (id, _) in BUILTIN {
let l = builtin(id).unwrap();
assert_eq!(l.data.len(), 33 * 33 * 33 * 3);
assert!(l.data.iter().all(|v| (0.0..=1.0).contains(v)), "{id}");
}
assert!(builtin("nope").is_none());
}
#[test]
fn cube_domain_survives_a_write() {
let cube = "DOMAIN_MIN 0.25 0.25 0.25
DOMAIN_MAX 0.75 0.75 0.75
LUT_3D_SIZE 2
0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1
";
let l = parse_cube(cube).expect("a cube with a non-default domain");
assert!(!l.domain_is_default());
let back = parse_cube(&write_cube(&l)).expect("roundtrip");
assert_eq!(back.domain_min, l.domain_min, "the written table must mean the same");
assert_eq!(back.domain_max, l.domain_max);
}
}