| // 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]); |
| } |
| } |