Skip to main content

jxl_encoder/headers/
color_encoding.rs

1// Copyright (c) Imazen LLC and the JPEG XL Project Authors.
2// Algorithms and constants derived from libjxl (BSD-3-Clause).
3// Licensed under AGPL-3.0-or-later. Commercial licenses at https://www.imazen.io/pricing
4
5//! Color encoding structures for JPEG XL.
6
7use crate::bit_writer::BitWriter;
8use crate::error::{Error, Result};
9
10/// CIE xy chromaticity coordinates.
11///
12/// Used to specify custom white points and primaries.
13/// Values are in the CIE 1931 xy chromaticity space.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct CIExy {
16    /// CIE x coordinate.
17    pub x: f64,
18    /// CIE y coordinate.
19    pub y: f64,
20}
21
22impl CIExy {
23    /// Creates a new CIE xy coordinate pair.
24    pub fn new(x: f64, y: f64) -> Self {
25        Self { x, y }
26    }
27}
28
29/// Custom primaries specified as three CIE xy coordinate pairs (red, green, blue).
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct CustomPrimaries {
32    /// Red primary CIE xy coordinates.
33    pub red: CIExy,
34    /// Green primary CIE xy coordinates.
35    pub green: CIExy,
36    /// Blue primary CIE xy coordinates.
37    pub blue: CIExy,
38}
39
40/// Rough limit for CIE xy coordinate values (absolute value must be less than this).
41const CUSTOMXY_ROUGH_LIMIT: f64 = 4.0;
42
43/// Multiplier for converting CIE xy float values to fixed-point integers.
44const CUSTOMXY_MUL: u32 = 1_000_000;
45
46/// Minimum allowed fixed-point value for a custom xy coordinate.
47const CUSTOMXY_MIN: i32 = -0x200000;
48
49/// Maximum allowed fixed-point value for a custom xy coordinate.
50const CUSTOMXY_MAX: i32 = 0x1FFFFF;
51
52/// Encodes a signed integer using JXL's PackSigned encoding.
53///
54/// Maps non-negative X to 2*X, negative -X to 2*X-1.
55/// This matches libjxl's `PackSigned` in `pack_signed.h`.
56fn pack_signed(value: i32) -> u32 {
57    ((value as u32) << 1) ^ (((!(value as u32)) >> 31).wrapping_sub(1))
58}
59
60/// Validates and converts a CIE xy float coordinate to a fixed-point integer.
61///
62/// Returns `Error::InvalidInput` if the value is out of range.
63fn xy_to_fixed(value: f64, name: &str) -> Result<i32> {
64    if value.abs() >= CUSTOMXY_ROUGH_LIMIT {
65        return Err(Error::InvalidInput(format!(
66            "custom {name} coordinate {value} out of range (must be < {CUSTOMXY_ROUGH_LIMIT})"
67        )));
68    }
69    let fixed = (value * f64::from(CUSTOMXY_MUL)).round() as i32;
70    if !(CUSTOMXY_MIN..=CUSTOMXY_MAX).contains(&fixed) {
71        return Err(Error::InvalidInput(format!(
72            "custom {name} coordinate {value} (fixed-point {fixed}) out of range [{CUSTOMXY_MIN}, {CUSTOMXY_MAX}]"
73        )));
74    }
75    Ok(fixed)
76}
77
78/// Writes a single custom xy coordinate to the bitstream.
79///
80/// Uses the JXL U32 encoding with distribution:
81/// - Selector 0: Bits(19), offset 0 — values 0..524287
82/// - Selector 1: BitsOffset(19, 524288) — values 524288..1048575
83/// - Selector 2: BitsOffset(20, 1048576) — values 1048576..2097151
84/// - Selector 3: BitsOffset(21, 2097152) — values 2097152..4194303
85///
86/// The input is a signed fixed-point integer that is first PackSigned'd.
87fn write_customxy_value(writer: &mut BitWriter, value: i32, name: &str) -> Result<()> {
88    let _ = name; // Used only in trace macros (compiled out without trace-bitstream feature)
89    let packed = pack_signed(value);
90
91    if packed < 524288 {
92        // Selector 0: Bits(19)
93        crate::trace::debug_eprintln!(
94            "CENC [bit {}]: {name} = {value} (packed {packed}, selector 0, 19 bits)",
95            writer.bits_written()
96        );
97        writer.write(2, 0)?;
98        writer.write(19, packed as u64)?;
99    } else if packed < 1048576 {
100        // Selector 1: BitsOffset(19, 524288)
101        crate::trace::debug_eprintln!(
102            "CENC [bit {}]: {name} = {value} (packed {packed}, selector 1, 19 bits + offset 524288)",
103            writer.bits_written()
104        );
105        writer.write(2, 1)?;
106        writer.write(19, (packed - 524288) as u64)?;
107    } else if packed < 2097152 {
108        // Selector 2: BitsOffset(20, 1048576)
109        crate::trace::debug_eprintln!(
110            "CENC [bit {}]: {name} = {value} (packed {packed}, selector 2, 20 bits + offset 1048576)",
111            writer.bits_written()
112        );
113        writer.write(2, 2)?;
114        writer.write(20, (packed - 1048576) as u64)?;
115    } else {
116        // Selector 3: BitsOffset(21, 2097152)
117        crate::trace::debug_eprintln!(
118            "CENC [bit {}]: {name} = {value} (packed {packed}, selector 3, 21 bits + offset 2097152)",
119            writer.bits_written()
120        );
121        writer.write(2, 3)?;
122        writer.write(21, (packed - 2097152) as u64)?;
123    }
124    Ok(())
125}
126
127/// Color space enumeration.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129#[repr(u8)]
130pub enum ColorSpace {
131    /// RGB color space.
132    #[default]
133    Rgb = 0,
134    /// Grayscale.
135    Gray = 1,
136    /// XYB (perceptual color space used internally by JXL).
137    Xyb = 2,
138    /// Unknown/custom color space.
139    Unknown = 3,
140}
141
142/// White point enumeration.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144#[repr(u8)]
145pub enum WhitePoint {
146    /// D65 white point (sRGB, Display P3).
147    #[default]
148    D65 = 1,
149    /// Custom white point.
150    Custom = 2,
151    /// E white point.
152    E = 10,
153    /// DCI white point.
154    Dci = 11,
155}
156
157/// Primaries enumeration.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159#[repr(u8)]
160pub enum Primaries {
161    /// sRGB primaries.
162    #[default]
163    Srgb = 1,
164    /// Custom primaries.
165    Custom = 2,
166    /// BT.2100 primaries.
167    Bt2100 = 9,
168    /// P3 primaries.
169    P3 = 11,
170}
171
172/// Transfer function enumeration.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174#[repr(u8)]
175pub enum TransferFunction {
176    /// BT.709 transfer function.
177    Bt709 = 1,
178    /// Unknown transfer function.
179    Unknown = 2,
180    /// Linear (gamma 1.0).
181    Linear = 8,
182    /// sRGB transfer function.
183    #[default]
184    Srgb = 13,
185    /// PQ (Perceptual Quantizer) for HDR.
186    Pq = 16,
187    /// DCI gamma (2.6).
188    Dci = 17,
189    /// HLG (Hybrid Log-Gamma) for HDR.
190    Hlg = 18,
191}
192
193/// Rendering intent.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195#[repr(u8)]
196pub enum RenderingIntent {
197    /// Perceptual (libjxl default for lossless encoding).
198    #[default]
199    Perceptual = 0,
200    /// Relative colorimetric.
201    Relative = 1,
202    /// Saturation.
203    Saturation = 2,
204    /// Absolute colorimetric.
205    Absolute = 3,
206}
207
208/// Complete color encoding specification.
209#[derive(Debug, Clone, Default)]
210pub struct ColorEncoding {
211    /// Color space.
212    pub color_space: ColorSpace,
213    /// White point.
214    pub white_point: WhitePoint,
215    /// Custom white point CIE xy coordinates (required when `white_point == WhitePoint::Custom`).
216    pub custom_white_point: Option<CIExy>,
217    /// Primaries (for RGB).
218    pub primaries: Primaries,
219    /// Custom primaries (required when `primaries == Primaries::Custom`).
220    pub custom_primaries: Option<CustomPrimaries>,
221    /// Transfer function.
222    pub transfer_function: TransferFunction,
223    /// Rendering intent.
224    pub rendering_intent: RenderingIntent,
225    /// Whether this uses an ICC profile.
226    pub want_icc: bool,
227    /// Custom gamma (encoding exponent). When Some, writes have_gamma=true + 24-bit value.
228    /// Example: 0.45455 for standard gamma 2.2 (display gamma = 1/0.45455 ≈ 2.2).
229    pub gamma: Option<f32>,
230}
231
232impl ColorEncoding {
233    /// Creates a standard sRGB color encoding.
234    pub fn srgb() -> Self {
235        Self {
236            color_space: ColorSpace::Rgb,
237            white_point: WhitePoint::D65,
238            custom_white_point: None,
239            primaries: Primaries::Srgb,
240            custom_primaries: None,
241            transfer_function: TransferFunction::Srgb,
242            rendering_intent: RenderingIntent::Perceptual,
243            want_icc: false,
244            gamma: None,
245        }
246    }
247
248    /// Creates a linear sRGB color encoding.
249    pub fn linear_srgb() -> Self {
250        Self {
251            color_space: ColorSpace::Rgb,
252            white_point: WhitePoint::D65,
253            custom_white_point: None,
254            primaries: Primaries::Srgb,
255            custom_primaries: None,
256            transfer_function: TransferFunction::Linear,
257            rendering_intent: RenderingIntent::Perceptual,
258            want_icc: false,
259            gamma: None,
260        }
261    }
262
263    /// Creates a grayscale sRGB color encoding.
264    pub fn gray() -> Self {
265        Self {
266            color_space: ColorSpace::Gray,
267            white_point: WhitePoint::D65,
268            custom_white_point: None,
269            primaries: Primaries::Srgb,
270            custom_primaries: None,
271            transfer_function: TransferFunction::Srgb,
272            rendering_intent: RenderingIntent::Perceptual,
273            want_icc: false,
274            gamma: None,
275        }
276    }
277
278    /// Creates a Display P3 color encoding.
279    pub fn display_p3() -> Self {
280        Self {
281            color_space: ColorSpace::Rgb,
282            white_point: WhitePoint::D65,
283            custom_white_point: None,
284            primaries: Primaries::P3,
285            custom_primaries: None,
286            transfer_function: TransferFunction::Srgb,
287            rendering_intent: RenderingIntent::Perceptual,
288            want_icc: false,
289            gamma: None,
290        }
291    }
292
293    /// Creates an sRGB color encoding with a custom gamma transfer function.
294    ///
295    /// Used for PNGs with `gAMA` chunk but no `sRGB` chunk. The gamma value
296    /// is the encoding exponent (e.g., 0.45455 for standard gamma 2.2).
297    pub fn with_gamma(gamma: f32) -> Self {
298        Self {
299            gamma: Some(gamma),
300            ..Self::srgb()
301        }
302    }
303
304    /// Creates a grayscale color encoding with a custom gamma transfer function.
305    pub fn gray_with_gamma(gamma: f32) -> Self {
306        Self {
307            gamma: Some(gamma),
308            ..Self::gray()
309        }
310    }
311
312    /// Creates a BT.2100 PQ (HDR) color encoding.
313    pub fn bt2100_pq() -> Self {
314        Self {
315            color_space: ColorSpace::Rgb,
316            white_point: WhitePoint::D65,
317            custom_white_point: None,
318            primaries: Primaries::Bt2100,
319            custom_primaries: None,
320            transfer_function: TransferFunction::Pq,
321            rendering_intent: RenderingIntent::Perceptual,
322            want_icc: false,
323            gamma: None,
324        }
325    }
326
327    /// Creates a grayscale color encoding.
328    pub fn grayscale() -> Self {
329        Self {
330            color_space: ColorSpace::Gray,
331            white_point: WhitePoint::D65,
332            custom_white_point: None,
333            primaries: Primaries::Srgb,
334            custom_primaries: None,
335            transfer_function: TransferFunction::Srgb,
336            rendering_intent: RenderingIntent::Perceptual,
337            want_icc: false,
338            gamma: None,
339        }
340    }
341
342    /// Creates a color encoding with a custom white point.
343    ///
344    /// The CIE xy coordinates specify the white point in the CIE 1931 chromaticity diagram.
345    /// For example, D50 is approximately (0.3457, 0.3585).
346    pub fn with_custom_white_point(white_point: CIExy) -> Self {
347        Self {
348            white_point: WhitePoint::Custom,
349            custom_white_point: Some(white_point),
350            ..Self::srgb()
351        }
352    }
353
354    /// Creates a color encoding with custom primaries.
355    ///
356    /// The three CIE xy coordinate pairs specify the red, green, and blue primaries.
357    pub fn with_custom_primaries(primaries: CustomPrimaries) -> Self {
358        Self {
359            primaries: Primaries::Custom,
360            custom_primaries: Some(primaries),
361            ..Self::srgb()
362        }
363    }
364
365    /// Creates a color encoding with both a custom white point and custom primaries.
366    pub fn with_custom_white_point_and_primaries(
367        white_point: CIExy,
368        primaries: CustomPrimaries,
369    ) -> Self {
370        Self {
371            white_point: WhitePoint::Custom,
372            custom_white_point: Some(white_point),
373            primaries: Primaries::Custom,
374            custom_primaries: Some(primaries),
375            ..Self::srgb()
376        }
377    }
378
379    /// Returns true if this matches the JXL default color encoding.
380    /// (sRGB with Perceptual rendering intent, no ICC)
381    ///
382    /// When all_default=true for metadata with xyb_encoded=true (lossy mode),
383    /// the decoder assumes sRGB input color space.
384    pub fn is_srgb(&self) -> bool {
385        self.color_space == ColorSpace::Rgb
386            && self.white_point == WhitePoint::D65
387            && self.custom_white_point.is_none()
388            && self.primaries == Primaries::Srgb
389            && self.custom_primaries.is_none()
390            && self.transfer_function == TransferFunction::Srgb
391            && self.rendering_intent == RenderingIntent::Perceptual
392            && !self.want_icc
393            && self.gamma.is_none()
394    }
395
396    /// Returns true if this is grayscale.
397    pub fn is_gray(&self) -> bool {
398        self.color_space == ColorSpace::Gray
399    }
400
401    /// Writes the color encoding to the bitstream.
402    pub fn write(&self, writer: &mut BitWriter) -> Result<()> {
403        // all_default flag
404        let all_default = self.is_srgb();
405        crate::trace::debug_eprintln!(
406            "CENC [bit {}]: all_default = {}",
407            writer.bits_written(),
408            all_default
409        );
410        writer.write_bit(all_default)?;
411
412        if all_default {
413            return Ok(());
414        }
415
416        // want_icc
417        crate::trace::debug_eprintln!(
418            "CENC [bit {}]: want_icc = {}",
419            writer.bits_written(),
420            self.want_icc
421        );
422        writer.write_bit(self.want_icc)?;
423
424        // color_space is ALWAYS written (even when want_icc=true, it affects decoding)
425        crate::trace::debug_eprintln!(
426            "CENC [bit {}]: color_space = {:?} ({})",
427            writer.bits_written(),
428            self.color_space,
429            self.color_space as u8
430        );
431        writer.write(2, self.color_space as u64)?;
432
433        if self.want_icc {
434            // When want_icc=true, white point/primaries/transfer/rendering intent are not written
435            return Ok(());
436        }
437
438        // white_point - uses jxl-rs default u2S(0, 1, Bits(4)+2, Bits(6)+18)
439        let wp = match self.white_point {
440            WhitePoint::D65 => 1,
441            WhitePoint::Custom => 2,
442            WhitePoint::E => 10,
443            WhitePoint::Dci => 11,
444        };
445        crate::trace::debug_eprintln!(
446            "CENC [bit {}]: white_point = {:?} ({})",
447            writer.bits_written(),
448            self.white_point,
449            wp
450        );
451        writer.write_enum_default(wp)?;
452        if self.white_point == WhitePoint::Custom {
453            let wp_xy = self.custom_white_point.ok_or_else(|| {
454                Error::InvalidInput(
455                    "custom_white_point must be set when white_point is Custom".into(),
456                )
457            })?;
458            let wx = xy_to_fixed(wp_xy.x, "white_point.x")?;
459            let wy = xy_to_fixed(wp_xy.y, "white_point.y")?;
460            write_customxy_value(writer, wx, "white_point.x")?;
461            write_customxy_value(writer, wy, "white_point.y")?;
462        }
463
464        // primaries (only for RGB) - uses jxl-rs default u2S encoding
465        if self.color_space == ColorSpace::Rgb {
466            let prim = match self.primaries {
467                Primaries::Srgb => 1,
468                Primaries::Custom => 2,
469                Primaries::Bt2100 => 9,
470                Primaries::P3 => 11,
471            };
472            crate::trace::debug_eprintln!(
473                "CENC [bit {}]: primaries = {:?} ({})",
474                writer.bits_written(),
475                self.primaries,
476                prim
477            );
478            writer.write_enum_default(prim)?;
479            if self.primaries == Primaries::Custom {
480                let cp = self.custom_primaries.ok_or_else(|| {
481                    Error::InvalidInput(
482                        "custom_primaries must be set when primaries is Custom".into(),
483                    )
484                })?;
485                // Red primary
486                let rx = xy_to_fixed(cp.red.x, "red.x")?;
487                let ry = xy_to_fixed(cp.red.y, "red.y")?;
488                write_customxy_value(writer, rx, "red.x")?;
489                write_customxy_value(writer, ry, "red.y")?;
490                // Green primary
491                let gx = xy_to_fixed(cp.green.x, "green.x")?;
492                let gy = xy_to_fixed(cp.green.y, "green.y")?;
493                write_customxy_value(writer, gx, "green.x")?;
494                write_customxy_value(writer, gy, "green.y")?;
495                // Blue primary
496                let bx = xy_to_fixed(cp.blue.x, "blue.x")?;
497                let by = xy_to_fixed(cp.blue.y, "blue.y")?;
498                write_customxy_value(writer, bx, "blue.x")?;
499                write_customxy_value(writer, by, "blue.y")?;
500            }
501        } else {
502            crate::trace::debug_eprintln!(
503                "CENC [bit {}]: primaries skipped (not RGB)",
504                writer.bits_written()
505            );
506        }
507
508        // have_gamma
509        let have_gamma = self.gamma.is_some();
510        crate::trace::debug_eprintln!(
511            "CENC [bit {}]: have_gamma = {}",
512            writer.bits_written(),
513            have_gamma
514        );
515        writer.write_bit(have_gamma)?;
516
517        if have_gamma {
518            let g = self.gamma.expect("gamma must be set when have_gamma=true");
519            // JXL spec: 24-bit integer = round(gamma * 10_000_000), clamped to [1, 2^24-1]
520            let encoded = (g * 10_000_000.0).round() as u32;
521            crate::trace::debug_eprintln!(
522                "CENC [bit {}]: gamma = {} (encoded {})",
523                writer.bits_written(),
524                g,
525                encoded
526            );
527            writer.write(24, encoded as u64)?;
528        } else {
529            // transfer_function - uses jxl-rs default u2S encoding
530            let tf = match self.transfer_function {
531                TransferFunction::Bt709 => 1,
532                TransferFunction::Unknown => 2,
533                TransferFunction::Linear => 8,
534                TransferFunction::Srgb => 13,
535                TransferFunction::Pq => 16,
536                TransferFunction::Dci => 17,
537                TransferFunction::Hlg => 18,
538            };
539            crate::trace::debug_eprintln!(
540                "CENC [bit {}]: transfer_function = {:?} ({})",
541                writer.bits_written(),
542                self.transfer_function,
543                tf
544            );
545            writer.write_enum_default(tf)?;
546        }
547
548        // rendering_intent
549        crate::trace::debug_eprintln!(
550            "CENC [bit {}]: rendering_intent = {:?} ({})",
551            writer.bits_written(),
552            self.rendering_intent,
553            self.rendering_intent as u8
554        );
555        writer.write(2, self.rendering_intent as u64)?;
556        crate::trace::debug_eprintln!("CENC [bit {}]: color_encoding done", writer.bits_written());
557
558        Ok(())
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_srgb_is_default() {
568        let enc = ColorEncoding::srgb();
569        // is_srgb() returns true for default sRGB encoding
570        // (enables all_default=true for metadata in XYB mode)
571        assert!(enc.is_srgb());
572    }
573
574    #[test]
575    fn test_write_srgb() {
576        let enc = ColorEncoding::srgb();
577        let mut writer = BitWriter::new();
578        enc.write(&mut writer).unwrap();
579        writer.zero_pad_to_byte();
580
581        // With is_srgb() returning true, all_default=true is written (1 bit)
582        // Padded to byte boundary = 8 bits
583        assert_eq!(writer.bits_written(), 8);
584    }
585
586    #[test]
587    fn test_write_non_default_srgb() {
588        // Non-default sRGB (Relative intent instead of Perceptual)
589        let enc = ColorEncoding {
590            rendering_intent: RenderingIntent::Relative, // Non-default
591            ..ColorEncoding::srgb()
592        };
593        let mut writer = BitWriter::new();
594        enc.write(&mut writer).unwrap();
595        writer.zero_pad_to_byte();
596
597        // With is_srgb() returning false (Relative != Perceptual),
598        // explicit color encoding is written:
599        // all_default=0 (1), want_icc=0 (1), color_space=0 (2),
600        // white_point D65=1 (2), primaries sRGB=1 (2), have_gamma=0 (1),
601        // transfer_function sRGB=13 (2+4=6), rendering_intent=1 (2)
602        // Total: 17 bits -> 24 bits padded = 3 bytes
603        assert_eq!(writer.bits_written(), 24);
604    }
605
606    #[test]
607    fn test_color_space_values() {
608        assert_eq!(ColorSpace::Rgb as u8, 0);
609        assert_eq!(ColorSpace::Gray as u8, 1);
610        assert_eq!(ColorSpace::Xyb as u8, 2);
611        assert_eq!(ColorSpace::Unknown as u8, 3);
612    }
613
614    #[test]
615    fn test_white_point_values() {
616        assert_eq!(WhitePoint::D65 as u8, 1);
617        assert_eq!(WhitePoint::Custom as u8, 2);
618        assert_eq!(WhitePoint::E as u8, 10);
619        assert_eq!(WhitePoint::Dci as u8, 11);
620    }
621
622    #[test]
623    fn test_primaries_values() {
624        assert_eq!(Primaries::Srgb as u8, 1);
625        assert_eq!(Primaries::Custom as u8, 2);
626        assert_eq!(Primaries::Bt2100 as u8, 9);
627        assert_eq!(Primaries::P3 as u8, 11);
628    }
629
630    #[test]
631    fn test_transfer_function_values() {
632        assert_eq!(TransferFunction::Bt709 as u8, 1);
633        assert_eq!(TransferFunction::Unknown as u8, 2);
634        assert_eq!(TransferFunction::Linear as u8, 8);
635        assert_eq!(TransferFunction::Srgb as u8, 13);
636        assert_eq!(TransferFunction::Pq as u8, 16);
637        assert_eq!(TransferFunction::Dci as u8, 17);
638        assert_eq!(TransferFunction::Hlg as u8, 18);
639    }
640
641    #[test]
642    fn test_rendering_intent_values() {
643        assert_eq!(RenderingIntent::Perceptual as u8, 0);
644        assert_eq!(RenderingIntent::Relative as u8, 1);
645        assert_eq!(RenderingIntent::Saturation as u8, 2);
646        assert_eq!(RenderingIntent::Absolute as u8, 3);
647    }
648
649    #[test]
650    fn test_write_linear_srgb() {
651        let enc = ColorEncoding::linear_srgb();
652        assert_eq!(enc.transfer_function, TransferFunction::Linear);
653
654        let mut writer = BitWriter::new();
655        enc.write(&mut writer).unwrap();
656        assert!(writer.bits_written() > 0);
657    }
658
659    #[test]
660    fn test_write_grayscale() {
661        let enc = ColorEncoding::grayscale();
662        assert!(enc.is_gray());
663        assert_eq!(enc.color_space, ColorSpace::Gray);
664
665        let mut writer = BitWriter::new();
666        enc.write(&mut writer).unwrap();
667        // Grayscale doesn't write primaries
668        assert!(writer.bits_written() > 0);
669    }
670
671    #[test]
672    fn test_write_gray() {
673        let enc = ColorEncoding::gray();
674        assert!(enc.is_gray());
675
676        let mut writer = BitWriter::new();
677        enc.write(&mut writer).unwrap();
678        assert!(writer.bits_written() > 0);
679    }
680
681    #[test]
682    fn test_write_display_p3() {
683        let enc = ColorEncoding::display_p3();
684        assert_eq!(enc.primaries, Primaries::P3);
685
686        let mut writer = BitWriter::new();
687        enc.write(&mut writer).unwrap();
688        assert!(writer.bits_written() > 0);
689    }
690
691    #[test]
692    fn test_write_bt2100_pq() {
693        let enc = ColorEncoding::bt2100_pq();
694        assert_eq!(enc.primaries, Primaries::Bt2100);
695        assert_eq!(enc.transfer_function, TransferFunction::Pq);
696
697        let mut writer = BitWriter::new();
698        enc.write(&mut writer).unwrap();
699        assert!(writer.bits_written() > 0);
700    }
701
702    #[test]
703    fn test_write_with_want_icc() {
704        let mut enc = ColorEncoding::srgb();
705        enc.want_icc = true;
706
707        let mut writer = BitWriter::new();
708        enc.write(&mut writer).unwrap();
709        // With want_icc=true: all_default=0 (1), want_icc=1 (1), color_space (2) = 4 bits
710        assert_eq!(writer.bits_written(), 4);
711    }
712
713    #[test]
714    fn test_write_bt709_transfer() {
715        let mut enc = ColorEncoding::srgb();
716        enc.transfer_function = TransferFunction::Bt709;
717
718        let mut writer = BitWriter::new();
719        enc.write(&mut writer).unwrap();
720        assert!(writer.bits_written() > 0);
721    }
722
723    #[test]
724    fn test_write_dci_transfer() {
725        let mut enc = ColorEncoding::srgb();
726        enc.transfer_function = TransferFunction::Dci;
727
728        let mut writer = BitWriter::new();
729        enc.write(&mut writer).unwrap();
730        assert!(writer.bits_written() > 0);
731    }
732
733    #[test]
734    fn test_write_hlg_transfer() {
735        let mut enc = ColorEncoding::srgb();
736        enc.transfer_function = TransferFunction::Hlg;
737
738        let mut writer = BitWriter::new();
739        enc.write(&mut writer).unwrap();
740        assert!(writer.bits_written() > 0);
741    }
742
743    #[test]
744    fn test_write_e_white_point() {
745        let mut enc = ColorEncoding::srgb();
746        enc.white_point = WhitePoint::E;
747
748        let mut writer = BitWriter::new();
749        enc.write(&mut writer).unwrap();
750        assert!(writer.bits_written() > 0);
751    }
752
753    #[test]
754    fn test_write_dci_white_point() {
755        let mut enc = ColorEncoding::srgb();
756        enc.white_point = WhitePoint::Dci;
757
758        let mut writer = BitWriter::new();
759        enc.write(&mut writer).unwrap();
760        assert!(writer.bits_written() > 0);
761    }
762
763    #[test]
764    fn test_rendering_intent_saturation() {
765        let mut enc = ColorEncoding::srgb();
766        enc.rendering_intent = RenderingIntent::Saturation;
767
768        let mut writer = BitWriter::new();
769        enc.write(&mut writer).unwrap();
770        assert!(writer.bits_written() > 0);
771    }
772
773    #[test]
774    fn test_rendering_intent_absolute() {
775        let mut enc = ColorEncoding::srgb();
776        enc.rendering_intent = RenderingIntent::Absolute;
777
778        let mut writer = BitWriter::new();
779        enc.write(&mut writer).unwrap();
780        assert!(writer.bits_written() > 0);
781    }
782
783    #[test]
784    fn test_xyb_color_space() {
785        let mut enc = ColorEncoding::srgb();
786        enc.color_space = ColorSpace::Xyb;
787
788        let mut writer = BitWriter::new();
789        enc.write(&mut writer).unwrap();
790        // XYB doesn't write primaries (not RGB)
791        assert!(writer.bits_written() > 0);
792    }
793
794    #[test]
795    fn test_unknown_color_space() {
796        let mut enc = ColorEncoding::srgb();
797        enc.color_space = ColorSpace::Unknown;
798
799        let mut writer = BitWriter::new();
800        enc.write(&mut writer).unwrap();
801        // Unknown color space doesn't write primaries
802        assert!(writer.bits_written() > 0);
803    }
804
805    #[test]
806    fn test_default_encoding() {
807        let enc = ColorEncoding::default();
808        assert_eq!(enc.color_space, ColorSpace::Rgb);
809        assert_eq!(enc.white_point, WhitePoint::D65);
810        assert_eq!(enc.primaries, Primaries::Srgb);
811        assert_eq!(enc.transfer_function, TransferFunction::Srgb);
812        assert_eq!(enc.rendering_intent, RenderingIntent::Perceptual);
813        assert!(!enc.want_icc);
814        assert!(enc.gamma.is_none());
815    }
816
817    #[test]
818    fn test_gamma_encoding() {
819        // Standard gamma 2.2: encoding exponent = 0.45455
820        let enc = ColorEncoding::with_gamma(0.45455);
821        assert!(!enc.is_srgb()); // gamma set → not sRGB default
822        assert_eq!(enc.gamma, Some(0.45455));
823
824        let mut writer = BitWriter::new();
825        enc.write(&mut writer).unwrap();
826        writer.zero_pad_to_byte();
827
828        // Verify: 0.45455 * 10_000_000 = 4_545_500
829        let encoded = (0.45455_f32 * 10_000_000.0).round() as u32;
830        assert_eq!(encoded, 4_545_500);
831
832        // Encoding should be longer than sRGB default (1 bit)
833        // all_default=0(1) + want_icc=0(1) + color_space=0(2) + white_point=1(2) +
834        // primaries=1(2) + have_gamma=1(1) + gamma(24) + rendering_intent=0(2) = 35 bits
835        assert_eq!(writer.bits_written(), 40); // 35 bits padded to 5 bytes
836    }
837
838    #[test]
839    fn test_gray_with_gamma() {
840        let enc = ColorEncoding::gray_with_gamma(0.45455);
841        assert!(enc.is_gray());
842        assert_eq!(enc.gamma, Some(0.45455));
843        assert!(!enc.is_srgb());
844
845        let mut writer = BitWriter::new();
846        enc.write(&mut writer).unwrap();
847        // Should write without error (grayscale skips primaries)
848        assert!(writer.bits_written() > 0);
849    }
850
851    // ---- pack_signed tests ----
852
853    #[test]
854    fn test_pack_signed_zero() {
855        assert_eq!(pack_signed(0), 0);
856    }
857
858    #[test]
859    fn test_pack_signed_positive() {
860        assert_eq!(pack_signed(1), 2);
861        assert_eq!(pack_signed(2), 4);
862        assert_eq!(pack_signed(100), 200);
863    }
864
865    #[test]
866    fn test_pack_signed_negative() {
867        assert_eq!(pack_signed(-1), 1);
868        assert_eq!(pack_signed(-2), 3);
869        assert_eq!(pack_signed(-100), 199);
870    }
871
872    #[test]
873    fn test_pack_signed_roundtrip() {
874        // Verify PackSigned is invertible (matches libjxl UnpackSigned)
875        for v in [-1000000, -1, 0, 1, 1000000, CUSTOMXY_MIN, CUSTOMXY_MAX] {
876            let packed = pack_signed(v);
877            // UnpackSigned: (packed >> 1) ^ (((!(packed)) & 1).wrapping_sub(1))
878            let unpacked = (packed >> 1) as i32 ^ (((!packed) & 1).wrapping_sub(1)) as i32;
879            assert_eq!(unpacked, v, "pack_signed roundtrip failed for {v}");
880        }
881    }
882
883    // ---- xy_to_fixed tests ----
884
885    #[test]
886    fn test_xy_to_fixed_d65() {
887        // D65 white point: (0.3127, 0.3290)
888        let x = xy_to_fixed(0.3127, "x").unwrap();
889        let y = xy_to_fixed(0.3290, "y").unwrap();
890        assert_eq!(x, 312700);
891        assert_eq!(y, 329000);
892    }
893
894    #[test]
895    fn test_xy_to_fixed_out_of_range() {
896        // Values >= 4.0 should fail
897        assert!(xy_to_fixed(4.0, "x").is_err());
898        assert!(xy_to_fixed(-4.0, "x").is_err());
899        // Values within rough limit but outside fixed-point range
900        assert!(xy_to_fixed(3.9, "x").is_err());
901    }
902
903    #[test]
904    fn test_xy_to_fixed_negative() {
905        let v = xy_to_fixed(-0.5, "x").unwrap();
906        assert_eq!(v, -500000);
907    }
908
909    // ---- Custom white point encoding tests ----
910
911    #[test]
912    fn test_write_custom_white_point_d50() {
913        // D50 white point: (0.3457, 0.3585)
914        let enc = ColorEncoding::with_custom_white_point(CIExy::new(0.3457, 0.3585));
915        assert_eq!(enc.white_point, WhitePoint::Custom);
916        assert!(enc.custom_white_point.is_some());
917        assert!(!enc.is_srgb());
918
919        let mut writer = BitWriter::new();
920        enc.write(&mut writer).unwrap();
921        // Should produce a valid bitstream with custom white point data
922        assert!(writer.bits_written() > 0);
923        // Custom white point adds: 2 coordinates * (2 selector + 19-21 data) bits each
924        // This should be longer than the standard D65 encoding
925    }
926
927    #[test]
928    fn test_write_custom_white_point_missing_coordinates() {
929        // Set white_point to Custom but don't provide coordinates
930        let enc = ColorEncoding {
931            white_point: WhitePoint::Custom,
932            custom_white_point: None,
933            ..ColorEncoding::srgb()
934        };
935
936        let mut writer = BitWriter::new();
937        let result = enc.write(&mut writer);
938        assert!(result.is_err());
939        let err = result.unwrap_err();
940        assert!(
941            err.to_string().contains("custom_white_point must be set"),
942            "unexpected error: {err}"
943        );
944    }
945
946    // ---- Custom primaries encoding tests ----
947
948    #[test]
949    fn test_write_custom_primaries() {
950        // Adobe RGB primaries
951        let primaries = CustomPrimaries {
952            red: CIExy::new(0.6400, 0.3300),
953            green: CIExy::new(0.2100, 0.7100),
954            blue: CIExy::new(0.1500, 0.0600),
955        };
956        let enc = ColorEncoding::with_custom_primaries(primaries);
957        assert_eq!(enc.primaries, Primaries::Custom);
958        assert!(enc.custom_primaries.is_some());
959        assert!(!enc.is_srgb());
960
961        let mut writer = BitWriter::new();
962        enc.write(&mut writer).unwrap();
963        // Should produce a valid bitstream with 6 custom xy values
964        assert!(writer.bits_written() > 0);
965    }
966
967    #[test]
968    fn test_write_custom_primaries_missing_coordinates() {
969        let enc = ColorEncoding {
970            primaries: Primaries::Custom,
971            custom_primaries: None,
972            ..ColorEncoding::srgb()
973        };
974
975        let mut writer = BitWriter::new();
976        let result = enc.write(&mut writer);
977        assert!(result.is_err());
978        let err = result.unwrap_err();
979        assert!(
980            err.to_string().contains("custom_primaries must be set"),
981            "unexpected error: {err}"
982        );
983    }
984
985    // ---- Combined custom white point + primaries ----
986
987    #[test]
988    fn test_write_custom_white_point_and_primaries() {
989        let enc = ColorEncoding::with_custom_white_point_and_primaries(
990            CIExy::new(0.3457, 0.3585), // D50
991            CustomPrimaries {
992                red: CIExy::new(0.7347, 0.2653),
993                green: CIExy::new(0.1596, 0.8404),
994                blue: CIExy::new(0.0366, 0.0001),
995            },
996        );
997        assert_eq!(enc.white_point, WhitePoint::Custom);
998        assert_eq!(enc.primaries, Primaries::Custom);
999
1000        let mut writer = BitWriter::new();
1001        enc.write(&mut writer).unwrap();
1002        // 2 wp coordinates + 6 primary coordinates = 8 customxy values
1003        assert!(writer.bits_written() > 0);
1004    }
1005
1006    // ---- Bit-level encoding verification ----
1007
1008    #[test]
1009    fn test_customxy_encoding_small_positive() {
1010        // A small positive value should use selector 0 (Bits(19))
1011        // D65 x = 0.3127 → fixed 312700 → packed = 625400
1012        // 625400 > 524287 → selector 1 (BitsOffset(19, 524288))
1013        let mut writer = BitWriter::new();
1014        write_customxy_value(&mut writer, 312700, "test").unwrap();
1015        let packed = pack_signed(312700);
1016        assert_eq!(packed, 625400);
1017        // selector 1 → 2 + 19 = 21 bits
1018        assert_eq!(writer.bits_written(), 21);
1019    }
1020
1021    #[test]
1022    fn test_customxy_encoding_zero() {
1023        // Zero should use selector 0
1024        let mut writer = BitWriter::new();
1025        write_customxy_value(&mut writer, 0, "test").unwrap();
1026        assert_eq!(pack_signed(0), 0);
1027        // selector 0 → 2 + 19 = 21 bits
1028        assert_eq!(writer.bits_written(), 21);
1029    }
1030
1031    #[test]
1032    fn test_customxy_encoding_negative() {
1033        // -1 → packed 1, selector 0
1034        let mut writer = BitWriter::new();
1035        write_customxy_value(&mut writer, -1, "test").unwrap();
1036        assert_eq!(pack_signed(-1), 1);
1037        assert_eq!(writer.bits_written(), 21); // selector 0: 2 + 19
1038    }
1039
1040    #[test]
1041    fn test_customxy_encoding_all_selectors() {
1042        // Verify each selector is chosen correctly based on packed value ranges
1043
1044        // Selector 0: packed 0..524287 (Bits(19))
1045        let mut w = BitWriter::new();
1046        // value 262143 → packed 524286
1047        write_customxy_value(&mut w, 262143, "test").unwrap();
1048        assert_eq!(w.bits_written(), 21); // 2 + 19
1049
1050        // Selector 1: packed 524288..1048575 (BitsOffset(19, 524288))
1051        let mut w = BitWriter::new();
1052        // value 262144 → packed 524288
1053        write_customxy_value(&mut w, 262144, "test").unwrap();
1054        assert_eq!(w.bits_written(), 21); // 2 + 19
1055
1056        // Selector 2: packed 1048576..2097151 (BitsOffset(20, 1048576))
1057        let mut w = BitWriter::new();
1058        // value 524288 → packed 1048576
1059        write_customxy_value(&mut w, 524288, "test").unwrap();
1060        assert_eq!(w.bits_written(), 22); // 2 + 20
1061
1062        // Selector 3: packed 2097152..4194303 (BitsOffset(21, 2097152))
1063        let mut w = BitWriter::new();
1064        // value 1048576 → packed 2097152
1065        write_customxy_value(&mut w, 1048576, "test").unwrap();
1066        assert_eq!(w.bits_written(), 23); // 2 + 21
1067    }
1068
1069    #[test]
1070    fn test_write_custom_wp_bit_count_vs_standard() {
1071        // Custom white point encoding should use more bits than D65
1072        let enc_d65 = ColorEncoding {
1073            rendering_intent: RenderingIntent::Relative,
1074            ..ColorEncoding::srgb()
1075        };
1076        let enc_custom = ColorEncoding {
1077            white_point: WhitePoint::Custom,
1078            custom_white_point: Some(CIExy::new(0.3127, 0.3290)),
1079            rendering_intent: RenderingIntent::Relative,
1080            ..ColorEncoding::srgb()
1081        };
1082
1083        let mut w_d65 = BitWriter::new();
1084        enc_d65.write(&mut w_d65).unwrap();
1085        let bits_d65 = w_d65.bits_written();
1086
1087        let mut w_custom = BitWriter::new();
1088        enc_custom.write(&mut w_custom).unwrap();
1089        let bits_custom = w_custom.bits_written();
1090
1091        assert!(
1092            bits_custom > bits_d65,
1093            "custom WP should use more bits: {bits_custom} vs {bits_d65}"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_default_encoding_custom_fields() {
1099        let enc = ColorEncoding::default();
1100        assert!(enc.custom_white_point.is_none());
1101        assert!(enc.custom_primaries.is_none());
1102    }
1103
1104    // ---- Grayscale with custom white point (no primaries written) ----
1105
1106    #[test]
1107    fn test_write_grayscale_custom_white_point() {
1108        let enc = ColorEncoding {
1109            color_space: ColorSpace::Gray,
1110            white_point: WhitePoint::Custom,
1111            custom_white_point: Some(CIExy::new(0.3457, 0.3585)),
1112            ..ColorEncoding::gray()
1113        };
1114
1115        let mut writer = BitWriter::new();
1116        enc.write(&mut writer).unwrap();
1117        // Grayscale skips primaries entirely, but custom WP should still be written
1118        assert!(writer.bits_written() > 0);
1119    }
1120
1121    // ---- Roundtrip decode tests with jxl-rs ----
1122
1123    #[test]
1124    fn test_roundtrip_custom_white_point_d50() {
1125        // Encode a small image with D50 custom white point, decode with jxl-rs
1126        let width = 16u32;
1127        let height = 16u32;
1128        let pixels: Vec<u8> = (0..width * height * 3).map(|i| (i % 256) as u8).collect();
1129
1130        let ce = ColorEncoding::with_custom_white_point(CIExy::new(0.3457, 0.3585));
1131
1132        let encoded = crate::LosslessConfig::new()
1133            .encode_request(width, height, crate::PixelLayout::Rgb8)
1134            .with_color_encoding(ce)
1135            .encode(&pixels)
1136            .expect("encoding with custom white point should succeed");
1137
1138        // Decode with jxl-rs (primary decoder)
1139        let decoded = crate::test_helpers::decode_with_jxl_rs(&encoded)
1140            .expect("jxl-rs should decode custom white point");
1141        assert_eq!(decoded.width, width as usize);
1142        assert_eq!(decoded.height, height as usize);
1143    }
1144
1145    #[test]
1146    fn test_roundtrip_custom_primaries() {
1147        // Encode with Adobe RGB-like custom primaries
1148        let width = 16u32;
1149        let height = 16u32;
1150        let pixels: Vec<u8> = (0..width * height * 3)
1151            .map(|i| ((i * 7) % 256) as u8)
1152            .collect();
1153
1154        let ce = ColorEncoding::with_custom_primaries(CustomPrimaries {
1155            red: CIExy::new(0.6400, 0.3300),
1156            green: CIExy::new(0.2100, 0.7100),
1157            blue: CIExy::new(0.1500, 0.0600),
1158        });
1159
1160        let encoded = crate::LosslessConfig::new()
1161            .encode_request(width, height, crate::PixelLayout::Rgb8)
1162            .with_color_encoding(ce)
1163            .encode(&pixels)
1164            .expect("encoding with custom primaries should succeed");
1165
1166        let decoded = crate::test_helpers::decode_with_jxl_rs(&encoded)
1167            .expect("jxl-rs should decode custom primaries");
1168        assert_eq!(decoded.width, width as usize);
1169        assert_eq!(decoded.height, height as usize);
1170    }
1171
1172    #[test]
1173    fn test_roundtrip_custom_white_point_and_primaries() {
1174        // ProPhoto RGB: D50 white point + wide gamut primaries
1175        let width = 16u32;
1176        let height = 16u32;
1177        let pixels: Vec<u8> = (0..width * height * 3)
1178            .map(|i| ((i * 13) % 256) as u8)
1179            .collect();
1180
1181        let ce = ColorEncoding::with_custom_white_point_and_primaries(
1182            CIExy::new(0.3457, 0.3585), // D50
1183            CustomPrimaries {
1184                red: CIExy::new(0.7347, 0.2653),
1185                green: CIExy::new(0.1596, 0.8404),
1186                blue: CIExy::new(0.0366, 0.0001),
1187            },
1188        );
1189
1190        let encoded = crate::LosslessConfig::new()
1191            .encode_request(width, height, crate::PixelLayout::Rgb8)
1192            .with_color_encoding(ce)
1193            .encode(&pixels)
1194            .expect("encoding with custom WP + primaries should succeed");
1195
1196        let decoded = crate::test_helpers::decode_with_jxl_rs(&encoded)
1197            .expect("jxl-rs should decode custom WP + primaries");
1198        assert_eq!(decoded.width, width as usize);
1199        assert_eq!(decoded.height, height as usize);
1200    }
1201}