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::Result;
9
10/// Color space enumeration.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[repr(u8)]
13pub enum ColorSpace {
14    /// RGB color space.
15    #[default]
16    Rgb = 0,
17    /// Grayscale.
18    Gray = 1,
19    /// XYB (perceptual color space used internally by JXL).
20    Xyb = 2,
21    /// Unknown/custom color space.
22    Unknown = 3,
23}
24
25/// White point enumeration.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27#[repr(u8)]
28pub enum WhitePoint {
29    /// D65 white point (sRGB, Display P3).
30    #[default]
31    D65 = 1,
32    /// Custom white point.
33    Custom = 2,
34    /// E white point.
35    E = 10,
36    /// DCI white point.
37    Dci = 11,
38}
39
40/// Primaries enumeration.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42#[repr(u8)]
43pub enum Primaries {
44    /// sRGB primaries.
45    #[default]
46    Srgb = 1,
47    /// Custom primaries.
48    Custom = 2,
49    /// BT.2100 primaries.
50    Bt2100 = 9,
51    /// P3 primaries.
52    P3 = 11,
53}
54
55/// Transfer function enumeration.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57#[repr(u8)]
58pub enum TransferFunction {
59    /// BT.709 transfer function.
60    Bt709 = 1,
61    /// Unknown transfer function.
62    Unknown = 2,
63    /// Linear (gamma 1.0).
64    Linear = 8,
65    /// sRGB transfer function.
66    #[default]
67    Srgb = 13,
68    /// PQ (Perceptual Quantizer) for HDR.
69    Pq = 16,
70    /// DCI gamma (2.6).
71    Dci = 17,
72    /// HLG (Hybrid Log-Gamma) for HDR.
73    Hlg = 18,
74}
75
76/// Rendering intent.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78#[repr(u8)]
79pub enum RenderingIntent {
80    /// Perceptual (libjxl default for lossless encoding).
81    #[default]
82    Perceptual = 0,
83    /// Relative colorimetric.
84    Relative = 1,
85    /// Saturation.
86    Saturation = 2,
87    /// Absolute colorimetric.
88    Absolute = 3,
89}
90
91/// Complete color encoding specification.
92#[derive(Debug, Clone, Default)]
93pub struct ColorEncoding {
94    /// Color space.
95    pub color_space: ColorSpace,
96    /// White point.
97    pub white_point: WhitePoint,
98    /// Primaries (for RGB).
99    pub primaries: Primaries,
100    /// Transfer function.
101    pub transfer_function: TransferFunction,
102    /// Rendering intent.
103    pub rendering_intent: RenderingIntent,
104    /// Whether this uses an ICC profile.
105    pub want_icc: bool,
106    /// Custom gamma (encoding exponent). When Some, writes have_gamma=true + 24-bit value.
107    /// Example: 0.45455 for standard gamma 2.2 (display gamma = 1/0.45455 ≈ 2.2).
108    pub gamma: Option<f32>,
109}
110
111impl ColorEncoding {
112    /// Creates a standard sRGB color encoding.
113    pub fn srgb() -> Self {
114        Self {
115            color_space: ColorSpace::Rgb,
116            white_point: WhitePoint::D65,
117            primaries: Primaries::Srgb,
118            transfer_function: TransferFunction::Srgb,
119            rendering_intent: RenderingIntent::Perceptual,
120            want_icc: false,
121            gamma: None,
122        }
123    }
124
125    /// Creates a linear sRGB color encoding.
126    pub fn linear_srgb() -> Self {
127        Self {
128            color_space: ColorSpace::Rgb,
129            white_point: WhitePoint::D65,
130            primaries: Primaries::Srgb,
131            transfer_function: TransferFunction::Linear,
132            rendering_intent: RenderingIntent::Perceptual,
133            want_icc: false,
134            gamma: None,
135        }
136    }
137
138    /// Creates a grayscale sRGB color encoding.
139    pub fn gray() -> Self {
140        Self {
141            color_space: ColorSpace::Gray,
142            white_point: WhitePoint::D65,
143            primaries: Primaries::Srgb,
144            transfer_function: TransferFunction::Srgb,
145            rendering_intent: RenderingIntent::Perceptual,
146            want_icc: false,
147            gamma: None,
148        }
149    }
150
151    /// Creates a Display P3 color encoding.
152    pub fn display_p3() -> Self {
153        Self {
154            color_space: ColorSpace::Rgb,
155            white_point: WhitePoint::D65,
156            primaries: Primaries::P3,
157            transfer_function: TransferFunction::Srgb,
158            rendering_intent: RenderingIntent::Perceptual,
159            want_icc: false,
160            gamma: None,
161        }
162    }
163
164    /// Creates an sRGB color encoding with a custom gamma transfer function.
165    ///
166    /// Used for PNGs with `gAMA` chunk but no `sRGB` chunk. The gamma value
167    /// is the encoding exponent (e.g., 0.45455 for standard gamma 2.2).
168    pub fn with_gamma(gamma: f32) -> Self {
169        Self {
170            gamma: Some(gamma),
171            ..Self::srgb()
172        }
173    }
174
175    /// Creates a grayscale color encoding with a custom gamma transfer function.
176    pub fn gray_with_gamma(gamma: f32) -> Self {
177        Self {
178            gamma: Some(gamma),
179            ..Self::gray()
180        }
181    }
182
183    /// Creates a BT.2100 PQ (HDR) color encoding.
184    pub fn bt2100_pq() -> Self {
185        Self {
186            color_space: ColorSpace::Rgb,
187            white_point: WhitePoint::D65,
188            primaries: Primaries::Bt2100,
189            transfer_function: TransferFunction::Pq,
190            rendering_intent: RenderingIntent::Perceptual,
191            want_icc: false,
192            gamma: None,
193        }
194    }
195
196    /// Creates a grayscale color encoding.
197    pub fn grayscale() -> Self {
198        Self {
199            color_space: ColorSpace::Gray,
200            white_point: WhitePoint::D65,
201            primaries: Primaries::Srgb,
202            transfer_function: TransferFunction::Srgb,
203            rendering_intent: RenderingIntent::Perceptual,
204            want_icc: false,
205            gamma: None,
206        }
207    }
208
209    /// Returns true if this matches the JXL default color encoding.
210    /// (sRGB with Perceptual rendering intent, no ICC)
211    ///
212    /// When all_default=true for metadata with xyb_encoded=true (lossy mode),
213    /// the decoder assumes sRGB input color space.
214    pub fn is_srgb(&self) -> bool {
215        self.color_space == ColorSpace::Rgb
216            && self.white_point == WhitePoint::D65
217            && self.primaries == Primaries::Srgb
218            && self.transfer_function == TransferFunction::Srgb
219            && self.rendering_intent == RenderingIntent::Perceptual
220            && !self.want_icc
221            && self.gamma.is_none()
222    }
223
224    /// Returns true if this is grayscale.
225    pub fn is_gray(&self) -> bool {
226        self.color_space == ColorSpace::Gray
227    }
228
229    /// Writes the color encoding to the bitstream.
230    pub fn write(&self, writer: &mut BitWriter) -> Result<()> {
231        // all_default flag
232        let all_default = self.is_srgb();
233        crate::trace::debug_eprintln!(
234            "CENC [bit {}]: all_default = {}",
235            writer.bits_written(),
236            all_default
237        );
238        writer.write_bit(all_default)?;
239
240        if all_default {
241            return Ok(());
242        }
243
244        // want_icc
245        crate::trace::debug_eprintln!(
246            "CENC [bit {}]: want_icc = {}",
247            writer.bits_written(),
248            self.want_icc
249        );
250        writer.write_bit(self.want_icc)?;
251
252        // color_space is ALWAYS written (even when want_icc=true, it affects decoding)
253        crate::trace::debug_eprintln!(
254            "CENC [bit {}]: color_space = {:?} ({})",
255            writer.bits_written(),
256            self.color_space,
257            self.color_space as u8
258        );
259        writer.write(2, self.color_space as u64)?;
260
261        if self.want_icc {
262            // When want_icc=true, white point/primaries/transfer/rendering intent are not written
263            return Ok(());
264        }
265
266        // white_point - uses jxl-rs default u2S(0, 1, Bits(4)+2, Bits(6)+18)
267        let wp = match self.white_point {
268            WhitePoint::D65 => 1,
269            WhitePoint::Custom => 2,
270            WhitePoint::E => 10,
271            WhitePoint::Dci => 11,
272        };
273        crate::trace::debug_eprintln!(
274            "CENC [bit {}]: white_point = {:?} ({})",
275            writer.bits_written(),
276            self.white_point,
277            wp
278        );
279        writer.write_enum_default(wp)?;
280        if self.white_point == WhitePoint::Custom {
281            // Custom white point coordinates would follow
282            todo!("Custom white point not implemented");
283        }
284
285        // primaries (only for RGB) - uses jxl-rs default u2S encoding
286        if self.color_space == ColorSpace::Rgb {
287            let prim = match self.primaries {
288                Primaries::Srgb => 1,
289                Primaries::Custom => 2,
290                Primaries::Bt2100 => 9,
291                Primaries::P3 => 11,
292            };
293            crate::trace::debug_eprintln!(
294                "CENC [bit {}]: primaries = {:?} ({})",
295                writer.bits_written(),
296                self.primaries,
297                prim
298            );
299            writer.write_enum_default(prim)?;
300            if self.primaries == Primaries::Custom {
301                // Custom primaries would follow
302                todo!("Custom primaries not implemented");
303            }
304        } else {
305            crate::trace::debug_eprintln!(
306                "CENC [bit {}]: primaries skipped (not RGB)",
307                writer.bits_written()
308            );
309        }
310
311        // have_gamma
312        let have_gamma = self.gamma.is_some();
313        crate::trace::debug_eprintln!(
314            "CENC [bit {}]: have_gamma = {}",
315            writer.bits_written(),
316            have_gamma
317        );
318        writer.write_bit(have_gamma)?;
319
320        if have_gamma {
321            let g = self.gamma.expect("gamma must be set when have_gamma=true");
322            // JXL spec: 24-bit integer = round(gamma * 10_000_000), clamped to [1, 2^24-1]
323            let encoded = (g * 10_000_000.0).round() as u32;
324            crate::trace::debug_eprintln!(
325                "CENC [bit {}]: gamma = {} (encoded {})",
326                writer.bits_written(),
327                g,
328                encoded
329            );
330            writer.write(24, encoded as u64)?;
331        } else {
332            // transfer_function - uses jxl-rs default u2S encoding
333            let tf = match self.transfer_function {
334                TransferFunction::Bt709 => 1,
335                TransferFunction::Unknown => 2,
336                TransferFunction::Linear => 8,
337                TransferFunction::Srgb => 13,
338                TransferFunction::Pq => 16,
339                TransferFunction::Dci => 17,
340                TransferFunction::Hlg => 18,
341            };
342            crate::trace::debug_eprintln!(
343                "CENC [bit {}]: transfer_function = {:?} ({})",
344                writer.bits_written(),
345                self.transfer_function,
346                tf
347            );
348            writer.write_enum_default(tf)?;
349        }
350
351        // rendering_intent
352        crate::trace::debug_eprintln!(
353            "CENC [bit {}]: rendering_intent = {:?} ({})",
354            writer.bits_written(),
355            self.rendering_intent,
356            self.rendering_intent as u8
357        );
358        writer.write(2, self.rendering_intent as u64)?;
359        crate::trace::debug_eprintln!("CENC [bit {}]: color_encoding done", writer.bits_written());
360
361        Ok(())
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_srgb_is_default() {
371        let enc = ColorEncoding::srgb();
372        // is_srgb() returns true for default sRGB encoding
373        // (enables all_default=true for metadata in XYB mode)
374        assert!(enc.is_srgb());
375    }
376
377    #[test]
378    fn test_write_srgb() {
379        let enc = ColorEncoding::srgb();
380        let mut writer = BitWriter::new();
381        enc.write(&mut writer).unwrap();
382        writer.zero_pad_to_byte();
383
384        // With is_srgb() returning true, all_default=true is written (1 bit)
385        // Padded to byte boundary = 8 bits
386        assert_eq!(writer.bits_written(), 8);
387    }
388
389    #[test]
390    fn test_write_non_default_srgb() {
391        // Non-default sRGB (Relative intent instead of Perceptual)
392        let enc = ColorEncoding {
393            color_space: ColorSpace::Rgb,
394            white_point: WhitePoint::D65,
395            primaries: Primaries::Srgb,
396            transfer_function: TransferFunction::Srgb,
397            rendering_intent: RenderingIntent::Relative, // Non-default
398            want_icc: false,
399            gamma: None,
400        };
401        let mut writer = BitWriter::new();
402        enc.write(&mut writer).unwrap();
403        writer.zero_pad_to_byte();
404
405        // With is_srgb() returning false (Relative != Perceptual),
406        // explicit color encoding is written:
407        // all_default=0 (1), want_icc=0 (1), color_space=0 (2),
408        // white_point D65=1 (2), primaries sRGB=1 (2), have_gamma=0 (1),
409        // transfer_function sRGB=13 (2+4=6), rendering_intent=1 (2)
410        // Total: 17 bits -> 24 bits padded = 3 bytes
411        assert_eq!(writer.bits_written(), 24);
412    }
413
414    #[test]
415    fn test_color_space_values() {
416        assert_eq!(ColorSpace::Rgb as u8, 0);
417        assert_eq!(ColorSpace::Gray as u8, 1);
418        assert_eq!(ColorSpace::Xyb as u8, 2);
419        assert_eq!(ColorSpace::Unknown as u8, 3);
420    }
421
422    #[test]
423    fn test_white_point_values() {
424        assert_eq!(WhitePoint::D65 as u8, 1);
425        assert_eq!(WhitePoint::Custom as u8, 2);
426        assert_eq!(WhitePoint::E as u8, 10);
427        assert_eq!(WhitePoint::Dci as u8, 11);
428    }
429
430    #[test]
431    fn test_primaries_values() {
432        assert_eq!(Primaries::Srgb as u8, 1);
433        assert_eq!(Primaries::Custom as u8, 2);
434        assert_eq!(Primaries::Bt2100 as u8, 9);
435        assert_eq!(Primaries::P3 as u8, 11);
436    }
437
438    #[test]
439    fn test_transfer_function_values() {
440        assert_eq!(TransferFunction::Bt709 as u8, 1);
441        assert_eq!(TransferFunction::Unknown as u8, 2);
442        assert_eq!(TransferFunction::Linear as u8, 8);
443        assert_eq!(TransferFunction::Srgb as u8, 13);
444        assert_eq!(TransferFunction::Pq as u8, 16);
445        assert_eq!(TransferFunction::Dci as u8, 17);
446        assert_eq!(TransferFunction::Hlg as u8, 18);
447    }
448
449    #[test]
450    fn test_rendering_intent_values() {
451        assert_eq!(RenderingIntent::Perceptual as u8, 0);
452        assert_eq!(RenderingIntent::Relative as u8, 1);
453        assert_eq!(RenderingIntent::Saturation as u8, 2);
454        assert_eq!(RenderingIntent::Absolute as u8, 3);
455    }
456
457    #[test]
458    fn test_write_linear_srgb() {
459        let enc = ColorEncoding::linear_srgb();
460        assert_eq!(enc.transfer_function, TransferFunction::Linear);
461
462        let mut writer = BitWriter::new();
463        enc.write(&mut writer).unwrap();
464        assert!(writer.bits_written() > 0);
465    }
466
467    #[test]
468    fn test_write_grayscale() {
469        let enc = ColorEncoding::grayscale();
470        assert!(enc.is_gray());
471        assert_eq!(enc.color_space, ColorSpace::Gray);
472
473        let mut writer = BitWriter::new();
474        enc.write(&mut writer).unwrap();
475        // Grayscale doesn't write primaries
476        assert!(writer.bits_written() > 0);
477    }
478
479    #[test]
480    fn test_write_gray() {
481        let enc = ColorEncoding::gray();
482        assert!(enc.is_gray());
483
484        let mut writer = BitWriter::new();
485        enc.write(&mut writer).unwrap();
486        assert!(writer.bits_written() > 0);
487    }
488
489    #[test]
490    fn test_write_display_p3() {
491        let enc = ColorEncoding::display_p3();
492        assert_eq!(enc.primaries, Primaries::P3);
493
494        let mut writer = BitWriter::new();
495        enc.write(&mut writer).unwrap();
496        assert!(writer.bits_written() > 0);
497    }
498
499    #[test]
500    fn test_write_bt2100_pq() {
501        let enc = ColorEncoding::bt2100_pq();
502        assert_eq!(enc.primaries, Primaries::Bt2100);
503        assert_eq!(enc.transfer_function, TransferFunction::Pq);
504
505        let mut writer = BitWriter::new();
506        enc.write(&mut writer).unwrap();
507        assert!(writer.bits_written() > 0);
508    }
509
510    #[test]
511    fn test_write_with_want_icc() {
512        let mut enc = ColorEncoding::srgb();
513        enc.want_icc = true;
514
515        let mut writer = BitWriter::new();
516        enc.write(&mut writer).unwrap();
517        // With want_icc=true: all_default=0 (1), want_icc=1 (1), color_space (2) = 4 bits
518        assert_eq!(writer.bits_written(), 4);
519    }
520
521    #[test]
522    fn test_write_bt709_transfer() {
523        let mut enc = ColorEncoding::srgb();
524        enc.transfer_function = TransferFunction::Bt709;
525
526        let mut writer = BitWriter::new();
527        enc.write(&mut writer).unwrap();
528        assert!(writer.bits_written() > 0);
529    }
530
531    #[test]
532    fn test_write_dci_transfer() {
533        let mut enc = ColorEncoding::srgb();
534        enc.transfer_function = TransferFunction::Dci;
535
536        let mut writer = BitWriter::new();
537        enc.write(&mut writer).unwrap();
538        assert!(writer.bits_written() > 0);
539    }
540
541    #[test]
542    fn test_write_hlg_transfer() {
543        let mut enc = ColorEncoding::srgb();
544        enc.transfer_function = TransferFunction::Hlg;
545
546        let mut writer = BitWriter::new();
547        enc.write(&mut writer).unwrap();
548        assert!(writer.bits_written() > 0);
549    }
550
551    #[test]
552    fn test_write_e_white_point() {
553        let mut enc = ColorEncoding::srgb();
554        enc.white_point = WhitePoint::E;
555
556        let mut writer = BitWriter::new();
557        enc.write(&mut writer).unwrap();
558        assert!(writer.bits_written() > 0);
559    }
560
561    #[test]
562    fn test_write_dci_white_point() {
563        let mut enc = ColorEncoding::srgb();
564        enc.white_point = WhitePoint::Dci;
565
566        let mut writer = BitWriter::new();
567        enc.write(&mut writer).unwrap();
568        assert!(writer.bits_written() > 0);
569    }
570
571    #[test]
572    fn test_rendering_intent_saturation() {
573        let mut enc = ColorEncoding::srgb();
574        enc.rendering_intent = RenderingIntent::Saturation;
575
576        let mut writer = BitWriter::new();
577        enc.write(&mut writer).unwrap();
578        assert!(writer.bits_written() > 0);
579    }
580
581    #[test]
582    fn test_rendering_intent_absolute() {
583        let mut enc = ColorEncoding::srgb();
584        enc.rendering_intent = RenderingIntent::Absolute;
585
586        let mut writer = BitWriter::new();
587        enc.write(&mut writer).unwrap();
588        assert!(writer.bits_written() > 0);
589    }
590
591    #[test]
592    fn test_xyb_color_space() {
593        let mut enc = ColorEncoding::srgb();
594        enc.color_space = ColorSpace::Xyb;
595
596        let mut writer = BitWriter::new();
597        enc.write(&mut writer).unwrap();
598        // XYB doesn't write primaries (not RGB)
599        assert!(writer.bits_written() > 0);
600    }
601
602    #[test]
603    fn test_unknown_color_space() {
604        let mut enc = ColorEncoding::srgb();
605        enc.color_space = ColorSpace::Unknown;
606
607        let mut writer = BitWriter::new();
608        enc.write(&mut writer).unwrap();
609        // Unknown color space doesn't write primaries
610        assert!(writer.bits_written() > 0);
611    }
612
613    #[test]
614    fn test_default_encoding() {
615        let enc = ColorEncoding::default();
616        assert_eq!(enc.color_space, ColorSpace::Rgb);
617        assert_eq!(enc.white_point, WhitePoint::D65);
618        assert_eq!(enc.primaries, Primaries::Srgb);
619        assert_eq!(enc.transfer_function, TransferFunction::Srgb);
620        assert_eq!(enc.rendering_intent, RenderingIntent::Perceptual);
621        assert!(!enc.want_icc);
622        assert!(enc.gamma.is_none());
623    }
624
625    #[test]
626    fn test_gamma_encoding() {
627        // Standard gamma 2.2: encoding exponent = 0.45455
628        let enc = ColorEncoding::with_gamma(0.45455);
629        assert!(!enc.is_srgb()); // gamma set → not sRGB default
630        assert_eq!(enc.gamma, Some(0.45455));
631
632        let mut writer = BitWriter::new();
633        enc.write(&mut writer).unwrap();
634        writer.zero_pad_to_byte();
635
636        // Verify: 0.45455 * 10_000_000 = 4_545_500
637        let encoded = (0.45455_f32 * 10_000_000.0).round() as u32;
638        assert_eq!(encoded, 4_545_500);
639
640        // Encoding should be longer than sRGB default (1 bit)
641        // all_default=0(1) + want_icc=0(1) + color_space=0(2) + white_point=1(2) +
642        // primaries=1(2) + have_gamma=1(1) + gamma(24) + rendering_intent=0(2) = 35 bits
643        assert_eq!(writer.bits_written(), 40); // 35 bits padded to 5 bytes
644    }
645
646    #[test]
647    fn test_gray_with_gamma() {
648        let enc = ColorEncoding::gray_with_gamma(0.45455);
649        assert!(enc.is_gray());
650        assert_eq!(enc.gamma, Some(0.45455));
651        assert!(!enc.is_srgb());
652
653        let mut writer = BitWriter::new();
654        enc.write(&mut writer).unwrap();
655        // Should write without error (grayscale skips primaries)
656        assert!(writer.bits_written() > 0);
657    }
658}