blob: 962f15cf4ab280f13f8da5d2e661a6b368a91263 [file]
// Copyright 2025 Google LLC
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! ICC profile parser FFI bindings.
//!
//! Provides C++ bindings for parsing ICC profiles using moxcms. All parsing
//! happens in Rust for memory safety, then validated data is converted to
//! skcms-compatible structures for color transformations.
// No `use moxcms::...` nor `use ffi::...` because we want the code to explicitly
// spell out if it means types from the moxcms crate vs types from the ffi module.
#[cxx::bridge(namespace = "rust_icc")]
mod ffi {
/// Color space types (matches skcms_Signature enum values).
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum skcms_Signature {
skcms_Signature_CMYK = 0x434D594B,
skcms_Signature_Gray = 0x47524159,
skcms_Signature_RGB = 0x52474220,
skcms_Signature_Lab = 0x4C616220,
skcms_Signature_XYZ = 0x58595A20,
skcms_Signature_CIELUV = 0x4C757620,
skcms_Signature_YCbCr = 0x59436272,
skcms_Signature_CIEYxy = 0x59787920,
skcms_Signature_HSV = 0x48535620,
skcms_Signature_HLS = 0x484C5320,
skcms_Signature_CMY = 0x434D5920,
skcms_Signature_2CLR = 0x32434C52,
skcms_Signature_3CLR = 0x33434C52,
skcms_Signature_4CLR = 0x34434C52,
skcms_Signature_5CLR = 0x35434C52,
skcms_Signature_6CLR = 0x36434C52,
skcms_Signature_7CLR = 0x37434C52,
skcms_Signature_8CLR = 0x38434C52,
skcms_Signature_9CLR = 0x39434C52,
skcms_Signature_10CLR = 0x41434C52,
skcms_Signature_11CLR = 0x42434C52,
skcms_Signature_12CLR = 0x43434C52,
skcms_Signature_13CLR = 0x44434C52,
skcms_Signature_14CLR = 0x45434C52,
skcms_Signature_15CLR = 0x46434C52,
}
// Extern enum definition to assure that CXX will generate static
// assertions to verify that the enum values match between Rust and C++.
extern "C++" {
include!("modules/skcms/skcms.h");
include!("rust/icc/FFI.h");
#[namespace = ""]
type skcms_Signature;
}
// The types below re-define C structs defined in `skcms_public.h`.
// The type definitions below have to be manually kept in-sync with the ones
// in `skcms_public.h`. Some guardrails exist, but they are not 100% accurate:
//
// * `static_assert`s in `rust/icc/FFI.cpp` verify that the 2 types are
// `std::is_layout_compatible` (this won't catch if the order of two
// field definitions is swapped for fields of the same type)
// * Tests in `tests/RustIccTest.cpp` verify round-tripping
// (this should catch issues with order of *known* fields)
//
// TODO(https://crbug.com/462751628): If all Skia clients using Rust codecs
// support `bindgen`, then it may be possible to avoid duplicating / redefining
// the types below.
/// 3x3 matrix for color space transforms (matches skcms_Matrix3x3).
struct Matrix3x3 {
vals: [[f32; 3]; 3],
}
/// Transfer function parameters (matches skcms_TransferFunction).
#[derive(Clone, Copy, Default)]
struct TransferFunction {
g: f32,
a: f32,
b: f32,
c: f32,
d: f32,
e: f32,
f: f32,
}
/// CICP color metadata (matches skcms_CICP).
#[derive(Clone, Copy)]
struct Cicp {
color_primaries: u8,
transfer_characteristics: u8,
matrix_coefficients: u8,
video_full_range_flag: u8,
}
/// Curve for LUT transforms - parametric or table-based (matches skcms_Curve).
struct Curve {
table_entries: u32, // 0 = parametric, >0 = table
parametric: TransferFunction,
table_data: Vec<u8>, // u16 values as bytes (little-endian) for tables
}
/// Device-to-PCS transform (matches skcms_A2B).
struct A2B {
input_curves: Vec<Curve>,
input_channels: u32,
grid_points: [u8; 4],
grid_data: Vec<u8>,
is_16bit_grid: bool,
matrix_curves: Vec<Curve>,
matrix: Matrix3x3,
matrix_bias: [f32; 3],
matrix_channels: u32,
output_curves: Vec<Curve>,
output_channels: u32,
}
/// PCS-to-device transform (matches skcms_B2A).
struct B2A {
/// Required: 3 1D "B" curves. Always present.
input_curves: Vec<Curve>,
input_channels: u32,
/// Optional: 3x4 matrix followed by 3 1D "M" curves.
/// If matrix_channels == 0, matrix and curves are skipped.
matrix: Matrix3x3,
matrix_bias: [f32; 3],
matrix_curves: Vec<Curve>,
matrix_channels: u32,
/// Optional: N-D CLUT followed by N 1D "A" curves.
/// If output_channels == 0, CLUT and curves are skipped.
grid_points: [u8; 4],
grid_data: Vec<u8>,
is_16bit_grid: bool,
output_curves: Vec<Curve>,
output_channels: u32,
}
/// Parsed ICC profile data.
struct IccProfile {
data_color_space: skcms_Signature,
connection_space: skcms_Signature,
to_xyzd50: Matrix3x3,
has_to_xyzd50: bool,
/// Transfer curves for R, G, B channels (or gray replicated x3).
trc_r: Curve,
trc_g: Curve,
trc_b: Curve,
has_trc: bool,
cicp: Cicp,
has_cicp: bool,
a2b: A2B,
has_a2b: bool,
b2a: B2A,
has_b2a: bool,
}
extern "Rust" {
/// Parses ICC profile from `data`. If successful, returns `true`
/// and writes result to `out`. If failure, returns `false`.
fn parse_icc_profile(data: &[u8], out: &mut IccProfile) -> bool;
}
}
/// Identifies whether we're parsing an A2B or B2A tag from the ICC profile.
/// This affects encoding factors applied during matrix conversion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LutTagType {
/// A2B tag (device-to-PCS transform)
A2B,
/// B2A tag (PCS-to-device transform)
B2A,
}
/// Parses ICC profile from `data`. If successful, returns `true`
/// and writes result to `out`. If failure, returns `false`.
pub fn parse_icc_profile(data: &[u8], out: &mut ffi::IccProfile) -> bool {
// Parse with moxcms (it validates size, signature, etc.)
let Ok(profile) = moxcms::ColorProfile::new_from_slice(data) else {
return false;
};
// Convert moxcms DataColorSpace to our skcms_Signature enum
// NOTE: This match is intentionally exhaustive (no catch-all pattern).
// If moxcms adds new DataColorSpace variants, this will fail to compile,
// forcing us to consciously decide how to map the new variant.
out.data_color_space = match profile.color_space {
moxcms::DataColorSpace::Xyz => ffi::skcms_Signature::skcms_Signature_XYZ,
moxcms::DataColorSpace::Lab => ffi::skcms_Signature::skcms_Signature_Lab,
moxcms::DataColorSpace::Luv => ffi::skcms_Signature::skcms_Signature_CIELUV,
moxcms::DataColorSpace::YCbr => ffi::skcms_Signature::skcms_Signature_YCbCr,
moxcms::DataColorSpace::Yxy => ffi::skcms_Signature::skcms_Signature_CIEYxy,
moxcms::DataColorSpace::Rgb => ffi::skcms_Signature::skcms_Signature_RGB,
moxcms::DataColorSpace::Gray => ffi::skcms_Signature::skcms_Signature_Gray,
moxcms::DataColorSpace::Hsv => ffi::skcms_Signature::skcms_Signature_HSV,
moxcms::DataColorSpace::Hls => ffi::skcms_Signature::skcms_Signature_HLS,
moxcms::DataColorSpace::Cmyk => ffi::skcms_Signature::skcms_Signature_CMYK,
moxcms::DataColorSpace::Cmy => ffi::skcms_Signature::skcms_Signature_CMY,
// Multi-channel color spaces recognized by skcms
moxcms::DataColorSpace::Color2 => ffi::skcms_Signature::skcms_Signature_2CLR,
moxcms::DataColorSpace::Color3 => ffi::skcms_Signature::skcms_Signature_3CLR,
moxcms::DataColorSpace::Color4 => ffi::skcms_Signature::skcms_Signature_4CLR,
moxcms::DataColorSpace::Color5 => ffi::skcms_Signature::skcms_Signature_5CLR,
moxcms::DataColorSpace::Color6 => ffi::skcms_Signature::skcms_Signature_6CLR,
moxcms::DataColorSpace::Color7 => ffi::skcms_Signature::skcms_Signature_7CLR,
moxcms::DataColorSpace::Color8 => ffi::skcms_Signature::skcms_Signature_8CLR,
moxcms::DataColorSpace::Color9 => ffi::skcms_Signature::skcms_Signature_9CLR,
moxcms::DataColorSpace::Color10 => ffi::skcms_Signature::skcms_Signature_10CLR,
moxcms::DataColorSpace::Color11 => ffi::skcms_Signature::skcms_Signature_11CLR,
moxcms::DataColorSpace::Color12 => ffi::skcms_Signature::skcms_Signature_12CLR,
moxcms::DataColorSpace::Color13 => ffi::skcms_Signature::skcms_Signature_13CLR,
moxcms::DataColorSpace::Color14 => ffi::skcms_Signature::skcms_Signature_14CLR,
moxcms::DataColorSpace::Color15 => ffi::skcms_Signature::skcms_Signature_15CLR,
};
// Profile Connection Space (PCS) must be XYZ or Lab per ICC spec.
// skcms only supports these two PCS values. Reject profiles with other values
// to match skcms_ParseWithA2BPriority behavior.
out.connection_space = match profile.pcs {
moxcms::DataColorSpace::Xyz => ffi::skcms_Signature::skcms_Signature_XYZ,
moxcms::DataColorSpace::Lab => ffi::skcms_Signature::skcms_Signature_Lab,
_ => return false, // Reject unsupported PCS
};
let matrix = profile.colorant_matrix();
out.to_xyzd50 = matrix3d_to_ffi(&matrix);
out.has_to_xyzd50 = is_valid_colorant_matrix(&matrix);
// For GRAY ICC profiles with XYZ PCS and no colorant matrix (no rXYZ/gXYZ/bXYZ
// tags), skcms synthesizes a diagonal toXYZD50 from the ICC header illuminant,
// which ICC spec requires to be D50.
if !out.has_to_xyzd50
&& profile.color_space == moxcms::DataColorSpace::Gray
&& profile.pcs == moxcms::DataColorSpace::Xyz
{
let wp = &profile.white_point;
out.to_xyzd50 = ffi::Matrix3x3 {
vals: [
[wp.x as f32, 0.0, 0.0],
[0.0, wp.y as f32, 0.0],
[0.0, 0.0, wp.z as f32],
],
};
out.has_to_xyzd50 = true;
}
out.has_trc = false;
if let Some(gray_trc) = &profile.gray_trc {
// GRAY profile: all three channels share the single kTRC curve.
// Call convert_trc_to_curve thrice to produce independent Curve
// values owning their own table_data bytes.
if let (Some(r_curve), Some(g_curve), Some(b_curve)) = (
convert_trc_to_curve(gray_trc),
convert_trc_to_curve(gray_trc),
convert_trc_to_curve(gray_trc),
) {
out.trc_r = r_curve;
out.trc_g = g_curve;
out.trc_b = b_curve;
out.has_trc = true;
}
} else if let (Some(r_curve), Some(g_curve), Some(b_curve)) = (
profile.red_trc.as_ref().and_then(convert_trc_to_curve),
profile.green_trc.as_ref().and_then(convert_trc_to_curve),
profile.blue_trc.as_ref().and_then(convert_trc_to_curve),
) {
out.trc_r = r_curve;
out.trc_g = g_curve;
out.trc_b = b_curve;
out.has_trc = true;
}
out.has_cicp = false;
if let Some(cicp) = &profile.cicp {
out.cicp = ffi::Cicp {
color_primaries: cicp.color_primaries as u8,
transfer_characteristics: cicp.transfer_characteristics as u8,
matrix_coefficients: cicp.matrix_coefficients as u8,
video_full_range_flag: if cicp.full_range { 1 } else { 0 },
};
out.has_cicp = true;
}
// Extract A2B transform (device-to-PCS with LUTs)
out.has_a2b = false;
if let Some(a2b) = profile
.lut_a_to_b_perceptual
.as_ref()
.or(profile.lut_a_to_b_colorimetric.as_ref())
.or(profile.lut_a_to_b_saturation.as_ref())
.and_then(|lut| convert_to_a2b(lut, profile.pcs, LutTagType::A2B))
{
out.a2b = a2b;
out.has_a2b = true;
}
// Extract B2A transform (PCS-to-device with LUTs)
out.has_b2a = false;
if let Some(a2b_data) = profile
.lut_b_to_a_perceptual
.as_ref()
.or(profile.lut_b_to_a_colorimetric.as_ref())
.or(profile.lut_b_to_a_saturation.as_ref())
.and_then(|lut| convert_to_a2b(lut, profile.pcs, LutTagType::B2A))
{
out.b2a = a2b_to_b2a(a2b_data);
out.has_b2a = true;
}
true
}
/// Convert Vec<u16> to Vec<u8> in big-endian byte order.
/// skcms expects all 16-bit table and grid data in big-endian (ICC native) format.
/// See skcms.cc eval_curve() and Transform_inl.h sample_clut_16().
fn u16_vec_to_bytes(values: &[u16]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(values.len() * 2);
for value in values {
bytes.extend(value.to_be_bytes());
}
bytes
}
/// Validate colorant matrix: each column must have at least one non-zero value.
/// Each column represents a colorant's contribution to XYZ (red, green, blue).
/// An all-zero column would mean that colorant contributes nothing, which is invalid.
/// moxcms returns a zero matrix when colorant tags (rXYZ, gXYZ, bXYZ) are missing.
fn is_valid_colorant_matrix(matrix: &moxcms::Matrix3d) -> bool {
let column_has_value = |col: usize| -> bool {
matrix.v[0][col] != 0.0 || matrix.v[1][col] != 0.0 || matrix.v[2][col] != 0.0
};
column_has_value(0) && column_has_value(1) && column_has_value(2)
}
/// Convert moxcms Matrix3d to FFI Matrix3x3.
fn matrix3d_to_ffi(matrix: &moxcms::Matrix3d) -> ffi::Matrix3x3 {
ffi::Matrix3x3 {
vals: [
[
matrix.v[0][0] as f32,
matrix.v[0][1] as f32,
matrix.v[0][2] as f32,
],
[
matrix.v[1][0] as f32,
matrix.v[1][1] as f32,
matrix.v[1][2] as f32,
],
[
matrix.v[2][0] as f32,
matrix.v[2][1] as f32,
matrix.v[2][2] as f32,
],
],
}
}
/// Convert LutStore to Vec<u16>, scaling 8-bit values to 16-bit range if needed.
fn lut_store_to_u16(store: &moxcms::LutStore) -> Vec<u16> {
match store {
moxcms::LutStore::Store8(data) => data
.iter()
.map(|&v| {
let v16 = v as u16;
(v16 << 8) | v16
})
.collect(),
moxcms::LutStore::Store16(data) => data.clone(),
}
}
/// Split a flat table into per-channel curves.
/// Returns empty Vec if table is too small for the expected layout.
fn split_table_to_curves(
table_data: &[u16],
entries_per_channel: usize,
num_channels: usize,
) -> Vec<ffi::Curve> {
if table_data.is_empty() || table_data.len() < entries_per_channel * num_channels {
return Vec::new();
}
(0..num_channels)
.map(|ch| {
let start = ch * entries_per_channel;
let end = start + entries_per_channel;
let channel_table = &table_data[start..end];
ffi::Curve {
table_entries: entries_per_channel as u32,
parametric: ffi::TransferFunction::default(),
table_data: u16_vec_to_bytes(channel_table),
}
})
.collect()
}
/// Convert A2B structure to B2A by reversing the transform direction.
/// B2A is the inverse of A2B: output curves become input, input curves become output.
fn a2b_to_b2a(a2b: ffi::A2B) -> ffi::B2A {
ffi::B2A {
input_curves: a2b.output_curves,
input_channels: a2b.output_channels,
matrix: a2b.matrix,
matrix_bias: a2b.matrix_bias,
matrix_curves: a2b.matrix_curves,
matrix_channels: a2b.matrix_channels,
grid_points: a2b.grid_points,
grid_data: a2b.grid_data,
is_16bit_grid: a2b.is_16bit_grid,
output_curves: a2b.input_curves,
output_channels: a2b.input_channels,
}
}
/// Converts LutStore grid data to bytes with suffix padding for skcms gather safety.
fn convert_grid_data(clut: &moxcms::LutStore) -> (Vec<u8>, bool) {
use moxcms::LutStore;
let (mut grid_data, is_16bit_grid) = match clut {
LutStore::Store8(data) => (data.clone(), false),
LutStore::Store16(data) => (u16_vec_to_bytes(data), true),
};
// ICC.1:2022 §7.1.2(c) requires tagged element data to be padded to a 4-byte
// boundary: "all tagged element data [...] shall be padded by no more than
// three following pad bytes to reach a 4-byte boundary".
// Spec: https://www.color.org/specification/ICC.1-2022-05.pdf
//
// skcms gather functions load wider than one CLUT entry:
// - gather_24 (8-bit 3-channel): loads 4 bytes per 3-byte entry → 1 byte overread
// - gather_48 (16-bit 3-channel): loads 8 bytes per 6-byte entry → 2 bytes overread
// Unlike skcms's C++ parser, which points grid_16/grid_8 into the full ICC
// profile buffer (where trailing tag data provides natural padding), the Rust
// bridge copies CLUT data into an isolated Vec. Add the overread bytes so the
// last gather stays within the allocation, then align to a 4-byte boundary per
// ICC.1:2022 §7.1.2(c).
// See modules/skcms/src/Transform_inl.h for gather implementations.
let overread: usize = if is_16bit_grid { 2 } else { 1 };
grid_data.resize((grid_data.len() + overread).next_multiple_of(4), 0);
(grid_data, is_16bit_grid)
}
/// Apply encoding factor to matrix and bias for PCS XYZ conversion.
/// Modifies matrix and bias in place.
///
/// skcms applies different encoding factors for A2B vs B2A tags when PCS is XYZ.
/// See modules/skcms/skcms.cc read_tag_mab (A2B) and read_tag_mba() (B2A)
fn apply_encoding_factor(
matrix: &mut ffi::Matrix3x3,
matrix_bias: &mut [f32; 3],
pcs: moxcms::DataColorSpace,
tag_type: LutTagType,
) {
if !matches!(pcs, moxcms::DataColorSpace::Xyz) {
return;
}
let encoding_factor = match tag_type {
LutTagType::A2B => 65535.0 / 32768.0,
LutTagType::B2A => 32768.0 / 65535.0,
};
for (i, bias) in matrix_bias.iter_mut().enumerate() {
for j in 0..3 {
matrix.vals[i][j] *= encoding_factor;
}
*bias *= encoding_factor;
}
}
/// Convert moxcms ParametricCurve to FFI TransferFunction.
fn parametric_curve_to_transfer_function(curve: &moxcms::ParametricCurve) -> ffi::TransferFunction {
ffi::TransferFunction {
g: curve.g,
a: curve.a,
b: curve.b,
c: curve.c,
d: curve.d,
e: curve.e,
f: curve.f,
}
}
/// Convert moxcms ToneReprCurve to FFI Curve structure.
/// Supports both parametric and table-based curves.
/// Returns None if the curve is empty or invalid.
fn convert_to_curve(trc: &moxcms::ToneReprCurve) -> Option<ffi::Curve> {
use moxcms::ToneReprCurve;
match trc {
ToneReprCurve::Parametric(params) => {
moxcms::ParametricCurve::new(params).map(|curve| ffi::Curve {
table_entries: 0,
parametric: parametric_curve_to_transfer_function(&curve),
table_data: Vec::new(),
})
}
ToneReprCurve::Lut(table) => {
if table.is_empty() {
return Some(ffi::Curve {
table_entries: 0,
parametric: ffi::TransferFunction {
g: 1.0,
a: 1.0,
b: 0.0,
c: 0.0,
d: 0.0,
e: 0.0,
f: 0.0,
},
table_data: Vec::new(),
});
}
Some(ffi::Curve {
table_entries: table.len() as u32,
parametric: ffi::TransferFunction::default(),
table_data: u16_vec_to_bytes(table),
})
}
}
}
/// Convert moxcms LutWarehouse to A2B structure.
/// Returns None if the LUT cannot be converted.
fn convert_to_a2b(
lut: &moxcms::LutWarehouse,
pcs: moxcms::DataColorSpace,
tag_type: LutTagType,
) -> Option<ffi::A2B> {
use moxcms::LutWarehouse;
match lut {
LutWarehouse::Multidimensional(mdt) => {
// ICC.1:2022 §7.2.6 defines colour spaces up to 15 channels
// (nCLR), but skcms_A2B/B2A structs use fixed-size arrays of
// 4 elements for input_curves[] and grid_points[], so we can
// only represent device spaces with 1-4 channels (up to CMYK).
// Reject anything outside that range (crbug.com/504160794).
// A2B output is always PCS (XYZ/Lab), which is 3-dimensional.
// Input channels are limited to 4 by skcms struct size.
// (crbug.com/506010945)
if mdt.num_input_channels > 4 || mdt.num_output_channels != 3 {
return None;
}
let input_curves: Vec<ffi::Curve> =
mdt.a_curves.iter().filter_map(convert_to_curve).collect();
let (grid_data, is_16bit_grid) = if let Some(ref clut) = mdt.clut {
convert_grid_data(clut)
} else {
(Vec::new(), false)
};
let matrix_curves: Vec<ffi::Curve> =
mdt.m_curves.iter().filter_map(convert_to_curve).collect();
let mut matrix = matrix3d_to_ffi(&mdt.matrix);
let mut matrix_bias = [
mdt.bias.v[0] as f32,
mdt.bias.v[1] as f32,
mdt.bias.v[2] as f32,
];
apply_encoding_factor(&mut matrix, &mut matrix_bias, pcs, tag_type);
let output_curves: Vec<ffi::Curve> =
mdt.b_curves.iter().filter_map(convert_to_curve).collect();
let grid_points: [u8; 4] = mdt.grid_points[..4].try_into().unwrap();
let matrix_channels = if matrix_curves.is_empty() { 0 } else { 3 };
if output_curves.is_empty() {
return None;
}
// ICC.1:2022 §10.14/§10.15: each active CLUT dimension must
// have at least 2 grid points. A zero would cause skcms clut()
// to underflow when computing grid_points[i] - 1 (crbug.com/504103236).
if !grid_data.is_empty() {
for i in 0..mdt.num_input_channels.min(4) as usize {
if grid_points[i] < 2 {
return None;
}
}
}
// If there is no CLUT, input and output channels must match
// and we set input_channels to 0 to signal "skip this stage"
let (final_input_channels, final_input_curves) = if grid_data.is_empty() {
if mdt.num_input_channels != mdt.num_output_channels {
return None;
}
(0, Vec::new())
} else {
if input_curves.is_empty() {
return None;
}
(mdt.num_input_channels as u32, input_curves)
};
Some(ffi::A2B {
input_curves: final_input_curves,
input_channels: final_input_channels,
grid_points,
grid_data,
is_16bit_grid,
matrix_curves,
matrix,
matrix_bias,
matrix_channels,
output_curves,
output_channels: mdt.num_output_channels as u32,
})
}
LutWarehouse::Lut(ldt) => {
// Legacy Lut8Type/Lut16Type (mft1/mft2 tags)
// Similar structure to Multidimensional, but uses uniform grid size
// Same channel-count constraint as Multidimensional above.
if ldt.num_input_channels > 4 || ldt.num_output_channels != 3 {
return None;
}
let input_curves: Vec<ffi::Curve> = {
let curve_data = lut_store_to_u16(&ldt.input_table);
split_table_to_curves(
&curve_data,
ldt.num_input_table_entries as usize,
ldt.num_input_channels as usize,
)
};
let (grid_data, is_16bit_grid) = convert_grid_data(&ldt.clut_table);
let grid_size = ldt.num_clut_grid_points;
let mut grid_points = [0u8; 4];
for i in 0..ldt.num_input_channels.min(4) as usize {
grid_points[i] = grid_size;
}
// Legacy lut8/lut16 types always have a CLUT. Each active
// dimension must have >= 2 grid points (crbug.com/504103236).
if grid_size < 2 {
return None;
}
let mut matrix = matrix3d_to_ffi(&ldt.matrix);
// Legacy LUT matrix is typically applied post-CLUT, so bias is zero
let mut matrix_bias = [0.0, 0.0, 0.0];
apply_encoding_factor(&mut matrix, &mut matrix_bias, pcs, tag_type);
let output_curves: Vec<ffi::Curve> = {
let curve_data = lut_store_to_u16(&ldt.output_table);
split_table_to_curves(
&curve_data,
ldt.num_output_table_entries as usize,
ldt.num_output_channels as usize,
)
};
let matrix_curves: Vec<ffi::Curve> = Vec::new();
let matrix_channels = 0;
Some(ffi::A2B {
input_curves,
input_channels: ldt.num_input_channels as u32,
grid_points,
grid_data,
is_16bit_grid,
matrix_curves,
matrix,
matrix_bias,
matrix_channels,
output_curves,
output_channels: ldt.num_output_channels as u32,
})
}
}
}
/// Convert moxcms ToneReprCurve to a skcms-compatible Curve.
///
/// For multi-entry Lut tables we pass the raw big-endian bytes through
/// directly instead of calling ApproximateCurve. skcms evaluates table
/// curves by exact interpolation, whereas the parametric approximation
/// produced by ApproximateCurve can differ by ±1 ULP at 8-bit output,
/// which manifests as a 1-value max-difference across the whole image.
///
/// Returns None if the curve cannot be converted (e.g. empty table or
/// malformed parametric).
fn convert_trc_to_curve(trc: &moxcms::ToneReprCurve) -> Option<ffi::Curve> {
use moxcms::ToneReprCurve;
match trc {
ToneReprCurve::Parametric(params) => {
// moxcms parametric curve: Vec<f32> with up to 7 parameters
// (g, a, b, c, d, e, f) matching skcms_TransferFunction layout.
moxcms::ParametricCurve::new(params).map(|curve| ffi::Curve {
table_entries: 0,
parametric: parametric_curve_to_transfer_function(&curve),
table_data: Vec::new(),
})
}
ToneReprCurve::Lut(table) => {
if table.len() == 1 {
// Single-entry curv tag encodes gamma in 8.8 fixed-point.
// Represent as a parametric power-law (g=gamma, a=1, rest 0).
let gamma = table[0] as f32 / 256.0;
Some(ffi::Curve {
table_entries: 0,
parametric: ffi::TransferFunction {
g: gamma,
a: 1.0,
b: 0.0,
c: 0.0,
d: 0.0,
e: 0.0,
f: 0.0,
},
table_data: Vec::new(),
})
} else if !table.is_empty() {
// Multi-entry curv tag: pass through as a big-endian u16 table.
// skcms interpolates the table exactly; no approximation needed.
Some(ffi::Curve {
table_entries: table.len() as u32,
parametric: ffi::TransferFunction::default(),
table_data: u16_vec_to_bytes(table),
})
} else {
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper to create an empty Curve (parametric, identity-like default).
fn empty_curve() -> ffi::Curve {
ffi::Curve {
table_entries: 0,
parametric: ffi::TransferFunction::default(),
table_data: Vec::new(),
}
}
/// Helper to create an empty IccProfile for testing
fn empty_icc_profile() -> ffi::IccProfile {
ffi::IccProfile {
data_color_space: ffi::skcms_Signature::skcms_Signature_RGB,
connection_space: ffi::skcms_Signature::skcms_Signature_XYZ,
to_xyzd50: ffi::Matrix3x3 {
vals: [[0.0; 3]; 3],
},
has_to_xyzd50: false,
trc_r: empty_curve(),
trc_g: empty_curve(),
trc_b: empty_curve(),
has_trc: false,
cicp: ffi::Cicp {
color_primaries: 0,
transfer_characteristics: 0,
matrix_coefficients: 0,
video_full_range_flag: 0,
},
has_cicp: false,
a2b: empty_a2b(),
has_a2b: false,
b2a: empty_b2a(),
has_b2a: false,
}
}
fn empty_a2b() -> ffi::A2B {
ffi::A2B {
input_curves: Vec::new(),
input_channels: 0,
grid_points: [0; 4],
grid_data: Vec::new(),
is_16bit_grid: false,
matrix_curves: Vec::new(),
matrix: ffi::Matrix3x3 {
vals: [[0.0; 3]; 3],
},
matrix_bias: [0.0; 3],
matrix_channels: 0,
output_curves: Vec::new(),
output_channels: 0,
}
}
fn empty_b2a() -> ffi::B2A {
ffi::B2A {
input_curves: Vec::new(),
input_channels: 0,
matrix: ffi::Matrix3x3 {
vals: [[0.0; 3]; 3],
},
matrix_bias: [0.0; 3],
matrix_curves: Vec::new(),
matrix_channels: 0,
grid_points: [0; 4],
grid_data: Vec::new(),
is_16bit_grid: false,
output_curves: Vec::new(),
output_channels: 0,
}
}
#[test]
fn test_empty_profile() {
let mut out = empty_icc_profile();
let result = parse_icc_profile(&[], &mut out);
assert!(!result);
}
#[test]
fn test_too_short() {
let mut out = empty_icc_profile();
let result = parse_icc_profile(&[0; 39], &mut out);
assert!(!result);
}
#[test]
fn test_invalid_signature() {
let mut data = vec![0u8; 128];
// Put wrong signature at offset 36
data[36..40].copy_from_slice(b"badd");
let mut out = empty_icc_profile();
let result = parse_icc_profile(&data, &mut out);
assert!(!result);
}
#[test]
fn test_valid_signature_invalid_profile() {
let mut data = vec![0u8; 128];
// Put correct ICC signature "acsp" at offset 36
data[36..40].copy_from_slice(b"acsp");
let mut out = empty_icc_profile();
let result = parse_icc_profile(&data, &mut out);
// This will fail moxcms parsing since it's not a real ICC profile
assert!(!result);
}
#[test]
fn test_valid_profile() {
// Sample from AdobeRGB1998.icc - a real 560-byte ICC profile
let data: [u8; 560] = [
0x00, 0x00, 0x02, 0x30, 0x41, 0x44, 0x42, 0x45, 0x02, 0x10, 0x00, 0x00, 0x6d, 0x6e,
0x74, 0x72, 0x52, 0x47, 0x42, 0x20, 0x58, 0x59, 0x5a, 0x20, 0x07, 0xd0, 0x00, 0x08,
0x00, 0x0b, 0x00, 0x13, 0x00, 0x33, 0x00, 0x3b, 0x61, 0x63, 0x73, 0x70, 0x41, 0x50,
0x50, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x6e, 0x6f, 0x6e, 0x65, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xf6, 0xd6, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xd3, 0x2d, 0x41, 0x44, 0x42, 0x45,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x63, 0x70, 0x72, 0x74, 0x00, 0x00, 0x00, 0xfc,
0x00, 0x00, 0x00, 0x32, 0x64, 0x65, 0x73, 0x63, 0x00, 0x00, 0x01, 0x30, 0x00, 0x00,
0x00, 0x6b, 0x77, 0x74, 0x70, 0x74, 0x00, 0x00, 0x01, 0x9c, 0x00, 0x00, 0x00, 0x14,
0x62, 0x6b, 0x70, 0x74, 0x00, 0x00, 0x01, 0xb0, 0x00, 0x00, 0x00, 0x14, 0x72, 0x54,
0x52, 0x43, 0x00, 0x00, 0x01, 0xc4, 0x00, 0x00, 0x00, 0x0e, 0x67, 0x54, 0x52, 0x43,
0x00, 0x00, 0x01, 0xd4, 0x00, 0x00, 0x00, 0x0e, 0x62, 0x54, 0x52, 0x43, 0x00, 0x00,
0x01, 0xe4, 0x00, 0x00, 0x00, 0x0e, 0x72, 0x58, 0x59, 0x5a, 0x00, 0x00, 0x01, 0xf4,
0x00, 0x00, 0x00, 0x14, 0x67, 0x58, 0x59, 0x5a, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00,
0x00, 0x14, 0x62, 0x58, 0x59, 0x5a, 0x00, 0x00, 0x02, 0x1c, 0x00, 0x00, 0x00, 0x14,
0x74, 0x65, 0x78, 0x74, 0x00, 0x00, 0x00, 0x00, 0x43, 0x6f, 0x70, 0x79, 0x72, 0x69,
0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x30, 0x30, 0x20, 0x41, 0x64, 0x6f, 0x62, 0x65,
0x20, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x20, 0x49, 0x6e, 0x63, 0x6f, 0x72,
0x70, 0x6f, 0x72, 0x61, 0x74, 0x65, 0x64, 0x00, 0x00, 0x00, 0x64, 0x65, 0x73, 0x63,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20,
0x52, 0x47, 0x42, 0x20, 0x28, 0x31, 0x39, 0x39, 0x38, 0x29, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x59, 0x5a, 0x20, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xf3, 0x51, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x16, 0xcc, 0x58, 0x59,
0x5a, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x63, 0x75, 0x72, 0x76, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x33, 0x00, 0x00, 0x63, 0x75, 0x72, 0x76, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x02, 0x33, 0x00, 0x00, 0x63, 0x75, 0x72, 0x76, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x33, 0x00, 0x00, 0x58, 0x59, 0x5a, 0x20,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9c, 0x18, 0x00, 0x00, 0x4f, 0xa5, 0x00, 0x00,
0x04, 0xfc, 0x58, 0x59, 0x5a, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x34, 0x8d,
0x00, 0x00, 0xa0, 0x2c, 0x00, 0x00, 0x0f, 0x95, 0x58, 0x59, 0x5a, 0x20, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x26, 0x31, 0x00, 0x00, 0x10, 0x2f, 0x00, 0x00, 0xbe, 0x9c,
];
let mut out = empty_icc_profile();
let result = parse_icc_profile(&data, &mut out);
assert!(result, "Failed to parse valid ICC profile");
assert_eq!(
out.data_color_space,
ffi::skcms_Signature::skcms_Signature_RGB
);
assert_eq!(
out.connection_space,
ffi::skcms_Signature::skcms_Signature_XYZ
);
// AdobeRGB has toXYZD50 matrix and TRCs
assert!(out.has_to_xyzd50, "AdobeRGB should have toXYZD50 matrix");
assert!(out.has_trc, "AdobeRGB should have TRCs");
// Verify the TRCs are gamma 2.2 (encoded as 563 in 8.8 fixed point = 2.199...)
// Single-entry Lut → parametric Curve (table_entries == 0).
assert!(
(out.trc_r.parametric.g - 2.2).abs() < 0.01,
"Red TRC should be ~2.2, got {}",
out.trc_r.parametric.g
);
}
#[test]
fn test_convert_legacy_lut_basic() {
use moxcms::{LutDataType, LutStore, LutType, LutWarehouse, Matrix3d};
// Create a simple legacy LUT with 3 input channels, 3 output channels
let ldt = LutDataType {
num_input_channels: 3,
num_output_channels: 3,
num_clut_grid_points: 2, // 2x2x2 grid
matrix: Matrix3d {
v: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
num_input_table_entries: 256,
num_output_table_entries: 256,
// Create identity input tables (3 channels * 256 entries)
input_table: LutStore::Store16(
(0..3)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
// Create simple CLUT (2^3 * 3 = 24 values)
clut_table: LutStore::Store16(vec![
0, 0, 0, // [0,0,0] -> [0,0,0]
65535, 0, 0, // [1,0,0] -> [1,0,0]
0, 65535, 0, // [0,1,0] -> [0,1,0]
65535, 65535, 0, // [1,1,0] -> [1,1,0]
0, 0, 65535, // [0,0,1] -> [0,0,1]
65535, 0, 65535, // [1,0,1] -> [1,0,1]
0, 65535, 65535, // [0,1,1] -> [0,1,1]
65535, 65535, 65535, // [1,1,1] -> [1,1,1]
]),
// Create identity output tables (3 channels * 256 entries)
output_table: LutStore::Store16(
(0..3)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
lut_type: LutType::Lut16,
};
let lut_warehouse = LutWarehouse::Lut(ldt);
let result = convert_to_a2b(&lut_warehouse, moxcms::DataColorSpace::Lab, LutTagType::A2B);
assert!(result.is_some(), "Should successfully convert legacy LUT");
let a2b = result.unwrap();
// Verify channel counts
assert_eq!(a2b.input_channels, 3);
assert_eq!(a2b.output_channels, 3);
// Verify grid points (uniform 2x2x2 for 3 input channels)
assert_eq!(a2b.grid_points, [2, 2, 2, 0]);
// Verify 3 input curves (one per channel)
assert_eq!(a2b.input_curves.len(), 3);
assert_eq!(a2b.input_curves[0].table_entries, 256);
// Verify 3 output curves (one per channel)
assert_eq!(a2b.output_curves.len(), 3);
assert_eq!(a2b.output_curves[0].table_entries, 256);
// Verify no matrix curves (legacy LUT doesn't have M curves)
assert_eq!(a2b.matrix_curves.len(), 0);
assert_eq!(a2b.matrix_channels, 0);
// Verify grid data is present and 16-bit
assert!(!a2b.grid_data.is_empty());
assert!(a2b.is_16bit_grid);
// Verify matrix is identity
assert_eq!(a2b.matrix.vals[0][0], 1.0);
assert_eq!(a2b.matrix.vals[1][1], 1.0);
assert_eq!(a2b.matrix.vals[2][2], 1.0);
// Verify bias is zero
assert_eq!(a2b.matrix_bias, [0.0, 0.0, 0.0]);
}
#[test]
fn test_convert_legacy_lut_8bit() {
use moxcms::{LutDataType, LutStore, LutType, LutWarehouse, Matrix3d};
// Create legacy 8-bit LUT
let ldt = LutDataType {
num_input_channels: 3,
num_output_channels: 3,
num_clut_grid_points: 3, // 3x3x3 grid
matrix: Matrix3d {
v: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
num_input_table_entries: 256,
num_output_table_entries: 256,
// Use 8-bit storage
input_table: LutStore::Store8((0..3).flat_map(|_| (0..256).map(|i| i as u8)).collect()),
clut_table: LutStore::Store8(vec![128; 3 * 3 * 3 * 3]), // 3^3 entries * 3 channels
output_table: LutStore::Store8(
(0..3).flat_map(|_| (0..256).map(|i| i as u8)).collect(),
),
lut_type: LutType::Lut8,
};
let lut_warehouse = LutWarehouse::Lut(ldt);
let result = convert_to_a2b(&lut_warehouse, moxcms::DataColorSpace::Lab, LutTagType::A2B);
assert!(
result.is_some(),
"Should successfully convert 8-bit legacy LUT"
);
let a2b = result.unwrap();
// Verify grid data is 8-bit
assert!(!a2b.is_16bit_grid);
assert!(!a2b.grid_data.is_empty());
// Verify input/output curves converted from 8-bit to 16-bit
assert_eq!(a2b.input_curves.len(), 3);
assert_eq!(a2b.output_curves.len(), 3);
// Each curve should have 256 entries * 2 bytes per entry
assert_eq!(a2b.input_curves[0].table_data.len(), 256 * 2);
}
#[test]
fn test_convert_legacy_lut_4channel() {
use moxcms::{LutDataType, LutStore, LutType, LutWarehouse, Matrix3d};
// Create 4-channel (CMYK) legacy LUT
let ldt = LutDataType {
num_input_channels: 4,
num_output_channels: 3,
num_clut_grid_points: 2, // 2x2x2x2 grid for 4 channels
matrix: Matrix3d {
v: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
num_input_table_entries: 256,
num_output_table_entries: 256,
input_table: LutStore::Store16(
(0..4)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
// 2^4 * 3 = 48 values
clut_table: LutStore::Store16(vec![32768; 16 * 3]),
output_table: LutStore::Store16(
(0..3)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
lut_type: LutType::Lut16,
};
let lut_warehouse = LutWarehouse::Lut(ldt);
let result = convert_to_a2b(&lut_warehouse, moxcms::DataColorSpace::Lab, LutTagType::A2B);
assert!(
result.is_some(),
"Should successfully convert 4-channel legacy LUT"
);
let a2b = result.unwrap();
// Verify 4 input channels
assert_eq!(a2b.input_channels, 4);
assert_eq!(a2b.input_curves.len(), 4);
// Verify grid points for 4D CLUT
assert_eq!(a2b.grid_points, [2, 2, 2, 2]);
// Verify 3 output channels (to XYZ/Lab)
assert_eq!(a2b.output_channels, 3);
assert_eq!(a2b.output_curves.len(), 3);
}
#[test]
fn test_convert_legacy_lut_empty_tables() {
use moxcms::{LutDataType, LutStore, LutType, LutWarehouse, Matrix3d};
// Create LUT with empty input/output tables
let ldt = LutDataType {
num_input_channels: 3,
num_output_channels: 3,
num_clut_grid_points: 2,
matrix: Matrix3d {
v: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
num_input_table_entries: 256,
num_output_table_entries: 256,
input_table: LutStore::Store16(Vec::new()), // Empty
clut_table: LutStore::Store16(vec![0; 8 * 3]),
output_table: LutStore::Store16(Vec::new()), // Empty
lut_type: LutType::Lut16,
};
let lut_warehouse = LutWarehouse::Lut(ldt);
let result = convert_to_a2b(&lut_warehouse, moxcms::DataColorSpace::Lab, LutTagType::A2B);
assert!(result.is_some(), "Should handle empty tables gracefully");
let a2b = result.unwrap();
// Should have empty curve vectors
assert_eq!(a2b.input_curves.len(), 0);
assert_eq!(a2b.output_curves.len(), 0);
// But still valid channel counts and grid
assert_eq!(a2b.input_channels, 3);
assert_eq!(a2b.output_channels, 3);
assert_eq!(a2b.grid_points, [2, 2, 2, 0]);
}
#[test]
fn test_convert_legacy_lut_non_identity_matrix() {
use moxcms::{LutDataType, LutStore, LutType, LutWarehouse, Matrix3d};
// Create LUT with non-identity matrix
let ldt = LutDataType {
num_input_channels: 3,
num_output_channels: 3,
num_clut_grid_points: 2,
matrix: Matrix3d {
v: [
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505],
],
},
num_input_table_entries: 256,
num_output_table_entries: 256,
input_table: LutStore::Store16(
(0..3)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
clut_table: LutStore::Store16(vec![32768; 8 * 3]),
output_table: LutStore::Store16(
(0..3)
.flat_map(|_| (0..256).map(|i| (i as u16) * 257))
.collect(),
),
lut_type: LutType::Lut16,
};
let lut_warehouse = LutWarehouse::Lut(ldt);
let result = convert_to_a2b(&lut_warehouse, moxcms::DataColorSpace::Lab, LutTagType::A2B);
assert!(result.is_some(), "Should convert LUT with custom matrix");
let a2b = result.unwrap();
// Verify matrix values (sRGB to XYZ-like)
assert!((a2b.matrix.vals[0][0] - 0.4124).abs() < 0.001);
assert!((a2b.matrix.vals[1][1] - 0.7152).abs() < 0.001);
assert!((a2b.matrix.vals[2][2] - 0.9505).abs() < 0.001);
assert_eq!(a2b.matrix_bias, [0.0, 0.0, 0.0]);
}
#[test]
fn test_u16_vec_to_bytes() {
let values: Vec<u16> = vec![0x0000, 0x00FF, 0xFF00, 0xFFFF];
let bytes = u16_vec_to_bytes(&values);
assert_eq!(bytes.len(), 8);
// Big-endian: high byte first, matching ICC/skcms expectations.
assert_eq!(bytes[0..2], [0x00, 0x00]);
assert_eq!(bytes[2..4], [0x00, 0xFF]);
assert_eq!(bytes[4..6], [0xFF, 0x00]);
assert_eq!(bytes[6..8], [0xFF, 0xFF]);
}
}