Skip to main content

oxidize_pdf/graphics/
lab_color.rs

1//! CIE Lab color space for PDF graphics according to ISO 32000-1 Section 8.6.5.4
2//!
3//! The Lab color space is a CIE-based color space with three components:
4//! - L* (lightness): 0 to 100
5//! - a* (green-red axis): typically -128 to 127
6//! - b* (blue-yellow axis): typically -128 to 127
7//!
8//! Lab provides device-independent color that is perceptually uniform.
9
10use crate::objects::{Dictionary, Object};
11
12/// CIE Lab color space (ISO 32000-1 §8.6.5.4)
13#[derive(Debug, Clone, PartialEq)]
14pub struct LabColorSpace {
15    /// White point in CIE XYZ coordinates [Xw, Yw, Zw]
16    /// Default is D50 standard illuminant
17    pub white_point: [f64; 3],
18    /// Black point in CIE XYZ coordinates [Xb, Yb, Zb]  
19    /// Default is [0, 0, 0]
20    pub black_point: [f64; 3],
21    /// Range for a* and b* components [a_min, a_max, b_min, b_max]
22    /// Default is [-100, 100, -100, 100]
23    pub range: [f64; 4],
24}
25
26impl Default for LabColorSpace {
27    fn default() -> Self {
28        Self {
29            white_point: [0.9505, 1.0000, 1.0890], // D50 standard illuminant
30            black_point: [0.0, 0.0, 0.0],
31            range: [-100.0, 100.0, -100.0, 100.0],
32        }
33    }
34}
35
36impl LabColorSpace {
37    /// Create a new Lab color space with default parameters
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Set the white point (CIE XYZ coordinates)
43    pub fn with_white_point(mut self, white_point: [f64; 3]) -> Self {
44        self.white_point = white_point;
45        self
46    }
47
48    /// Set the black point (CIE XYZ coordinates)
49    pub fn with_black_point(mut self, black_point: [f64; 3]) -> Self {
50        self.black_point = black_point;
51        self
52    }
53
54    /// Set the range for a* and b* components
55    pub fn with_range(mut self, a_min: f64, a_max: f64, b_min: f64, b_max: f64) -> Self {
56        self.range = [a_min, a_max, b_min, b_max];
57        self
58    }
59
60    /// Common D50 illuminant (default)
61    pub fn d50() -> Self {
62        Self::new()
63    }
64
65    /// Common D65 illuminant
66    pub fn d65() -> Self {
67        Self::new().with_white_point([0.9504, 1.0000, 1.0888])
68    }
69
70    /// Build the `Lab` parameter dictionary (`WhitePoint`, optional
71    /// `BlackPoint`/`Range`). Single source of the dict shape, shared by
72    /// [`Self::to_pdf_array`] and the `PageColorSpace` bridge
73    /// (`impl From<&LabColorSpace> for PageColorSpace`).
74    pub fn params_dictionary(&self) -> Dictionary {
75        let mut dict = Dictionary::new();
76
77        // White point (required)
78        dict.set(
79            "WhitePoint",
80            Object::Array(self.white_point.iter().map(|&x| Object::Real(x)).collect()),
81        );
82
83        // Black point (optional, only include if not default)
84        if self.black_point != [0.0, 0.0, 0.0] {
85            dict.set(
86                "BlackPoint",
87                Object::Array(self.black_point.iter().map(|&x| Object::Real(x)).collect()),
88            );
89        }
90
91        // Range (optional, only include if not default)
92        if self.range != [-100.0, 100.0, -100.0, 100.0] {
93            dict.set(
94                "Range",
95                Object::Array(self.range.iter().map(|&x| Object::Real(x)).collect()),
96            );
97        }
98
99        dict
100    }
101
102    /// Convert to PDF color space array
103    pub fn to_pdf_array(&self) -> Vec<Object> {
104        vec![
105            Object::Name("Lab".to_string()),
106            Object::Dictionary(self.params_dictionary()),
107        ]
108    }
109
110    /// Convert Lab values to CIE XYZ
111    /// L* is in range [0, 100], a* and b* are typically in [-128, 127]
112    pub fn lab_to_xyz(&self, l: f64, a: f64, b: f64) -> [f64; 3] {
113        // Constants
114        const EPSILON: f64 = 216.0 / 24389.0; // 6³/29³
115        const KAPPA: f64 = 24389.0 / 27.0; // 29³/3³
116
117        // Normalize L* to [0, 1] range
118        let fy = (l + 16.0) / 116.0;
119        let fx = fy + (a / 500.0);
120        let fz = fy - (b / 200.0);
121
122        // Convert to XYZ
123        let x = if fx.powi(3) > EPSILON {
124            fx.powi(3)
125        } else {
126            (116.0 * fx - 16.0) / KAPPA
127        };
128
129        let y = if l > KAPPA * EPSILON {
130            fy.powi(3)
131        } else {
132            l / KAPPA
133        };
134
135        let z = if fz.powi(3) > EPSILON {
136            fz.powi(3)
137        } else {
138            (116.0 * fz - 16.0) / KAPPA
139        };
140
141        // Scale by white point
142        [
143            x * self.white_point[0],
144            y * self.white_point[1],
145            z * self.white_point[2],
146        ]
147    }
148
149    /// Convert CIE XYZ to Lab values
150    pub fn xyz_to_lab(&self, x: f64, y: f64, z: f64) -> [f64; 3] {
151        // Constants
152        const EPSILON: f64 = 216.0 / 24389.0; // 6³/29³
153        const KAPPA: f64 = 24389.0 / 27.0; // 29³/3³
154
155        // Normalize by white point
156        let xn = x / self.white_point[0];
157        let yn = y / self.white_point[1];
158        let zn = z / self.white_point[2];
159
160        // Apply transformation
161        let fx = if xn > EPSILON {
162            xn.cbrt()
163        } else {
164            (KAPPA * xn + 16.0) / 116.0
165        };
166
167        let fy = if yn > EPSILON {
168            yn.cbrt()
169        } else {
170            (KAPPA * yn + 16.0) / 116.0
171        };
172
173        let fz = if zn > EPSILON {
174            zn.cbrt()
175        } else {
176            (KAPPA * zn + 16.0) / 116.0
177        };
178
179        // Calculate Lab values
180        let l = 116.0 * fy - 16.0;
181        let a = 500.0 * (fx - fy);
182        let b = 200.0 * (fy - fz);
183
184        [l, a, b]
185    }
186
187    /// Convert Lab to approximate sRGB for display purposes
188    /// This is a convenience method for visualization
189    pub fn lab_to_rgb(&self, l: f64, a: f64, b: f64) -> [f64; 3] {
190        let [x, y, z] = self.lab_to_xyz(l, a, b);
191
192        // XYZ to sRGB matrix (D50 adapted)
193        let r = 3.2406 * x - 1.5372 * y - 0.4986 * z;
194        let g = -0.9689 * x + 1.8758 * y + 0.0415 * z;
195        let b = 0.0557 * x - 0.2040 * y + 1.0570 * z;
196
197        // Apply gamma correction and clamp
198        [
199            gamma_correct(r).clamp(0.0, 1.0),
200            gamma_correct(g).clamp(0.0, 1.0),
201            gamma_correct(b).clamp(0.0, 1.0),
202        ]
203    }
204
205    /// Convert sRGB to Lab for convenience
206    pub fn rgb_to_lab(&self, r: f64, g: f64, b: f64) -> [f64; 3] {
207        // Remove gamma correction
208        let r_linear = inverse_gamma_correct(r);
209        let g_linear = inverse_gamma_correct(g);
210        let b_linear = inverse_gamma_correct(b);
211
212        // sRGB to XYZ matrix (D50 adapted)
213        let x = 0.4124 * r_linear + 0.3576 * g_linear + 0.1805 * b_linear;
214        let y = 0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear;
215        let z = 0.0193 * r_linear + 0.1192 * g_linear + 0.9505 * b_linear;
216
217        self.xyz_to_lab(x, y, z)
218    }
219
220    /// Calculate color difference (Delta E) between two Lab colors
221    /// Uses CIE76 formula (Euclidean distance)
222    pub fn delta_e(&self, lab1: [f64; 3], lab2: [f64; 3]) -> f64 {
223        let dl = lab1[0] - lab2[0];
224        let da = lab1[1] - lab2[1];
225        let db = lab1[2] - lab2[2];
226
227        (dl * dl + da * da + db * db).sqrt()
228    }
229
230    /// Calculate perceptual color difference (Delta E 2000)
231    /// More accurate for small color differences
232    pub fn delta_e_2000(&self, lab1: [f64; 3], lab2: [f64; 3]) -> f64 {
233        // Simplified CIE Delta E 2000 formula
234        // Full implementation would include rotation term
235        let [l1, a1, b1] = lab1;
236        let [l2, a2, b2] = lab2;
237
238        let dl = l2 - l1;
239        let l_avg = (l1 + l2) / 2.0;
240
241        let c1 = (a1 * a1 + b1 * b1).sqrt();
242        let c2 = (a2 * a2 + b2 * b2).sqrt();
243        let c_avg = (c1 + c2) / 2.0;
244
245        let g = 0.5 * (1.0 - (c_avg.powi(7) / (c_avg.powi(7) + 25.0_f64.powi(7))).sqrt());
246        let a1_prime = a1 * (1.0 + g);
247        let a2_prime = a2 * (1.0 + g);
248
249        let c1_prime = (a1_prime * a1_prime + b1 * b1).sqrt();
250        let c2_prime = (a2_prime * a2_prime + b2 * b2).sqrt();
251        let dc_prime = c2_prime - c1_prime;
252
253        let h1_prime = b1.atan2(a1_prime).to_degrees();
254        let h2_prime = b2.atan2(a2_prime).to_degrees();
255
256        let dh_prime = if (h2_prime - h1_prime).abs() <= 180.0 {
257            h2_prime - h1_prime
258        } else if h2_prime - h1_prime > 180.0 {
259            h2_prime - h1_prime - 360.0
260        } else {
261            h2_prime - h1_prime + 360.0
262        };
263
264        let dh_prime_rad = dh_prime.to_radians();
265        let dh = 2.0 * (c1_prime * c2_prime).sqrt() * (dh_prime_rad / 2.0).sin();
266
267        // Weighting factors (simplified, using default values)
268        let kl = 1.0;
269        let kc = 1.0;
270        let kh = 1.0;
271
272        let sl = 1.0 + (0.015 * (l_avg - 50.0).powi(2) / (20.0 + (l_avg - 50.0).powi(2)).sqrt());
273        let sc = 1.0 + 0.045 * c_avg;
274        let sh = 1.0 + 0.015 * c_avg;
275
276        let dl_scaled = dl / (kl * sl);
277        let dc_scaled = dc_prime / (kc * sc);
278        let dh_scaled = dh / (kh * sh);
279
280        (dl_scaled.powi(2) + dc_scaled.powi(2) + dh_scaled.powi(2)).sqrt()
281    }
282}
283
284/// Helper function for sRGB gamma correction
285fn gamma_correct(linear: f64) -> f64 {
286    if linear <= 0.0031308 {
287        12.92 * linear
288    } else {
289        1.055 * linear.powf(1.0 / 2.4) - 0.055
290    }
291}
292
293/// Helper function for inverse sRGB gamma correction
294fn inverse_gamma_correct(srgb: f64) -> f64 {
295    if srgb <= 0.04045 {
296        srgb / 12.92
297    } else {
298        ((srgb + 0.055) / 1.055).powf(2.4)
299    }
300}
301
302/// Color value in Lab color space
303#[derive(Debug, Clone, PartialEq)]
304pub struct LabColor {
305    /// L* component (lightness, 0 to 100)
306    pub l: f64,
307    /// a* component (green-red axis)
308    pub a: f64,
309    /// b* component (blue-yellow axis)
310    pub b: f64,
311    /// Associated color space
312    pub color_space: LabColorSpace,
313}
314
315impl LabColor {
316    /// Create a new Lab color
317    pub fn new(l: f64, a: f64, b: f64, color_space: LabColorSpace) -> Self {
318        // Clamp L to valid range
319        let l = l.clamp(0.0, 100.0);
320
321        // Clamp a and b to color space range
322        let a = a.clamp(color_space.range[0], color_space.range[1]);
323        let b = b.clamp(color_space.range[2], color_space.range[3]);
324
325        Self {
326            l,
327            a,
328            b,
329            color_space,
330        }
331    }
332
333    /// Create Lab color with default D50 color space
334    pub fn with_default(l: f64, a: f64, b: f64) -> Self {
335        Self::new(l, a, b, LabColorSpace::default())
336    }
337
338    /// Get the color space array for PDF
339    pub fn color_space_array(&self) -> Vec<Object> {
340        self.color_space.to_pdf_array()
341    }
342
343    /// Get the color values as an array
344    pub fn values(&self) -> Vec<f64> {
345        // PDF expects normalized values
346        // L* from 0-100 to 0-100 (no change)
347        // a* and b* need to be normalized based on range
348        let a_normalized = (self.a - self.color_space.range[0])
349            / (self.color_space.range[1] - self.color_space.range[0]);
350        let b_normalized = (self.b - self.color_space.range[2])
351            / (self.color_space.range[3] - self.color_space.range[2]);
352
353        vec![self.l / 100.0, a_normalized, b_normalized]
354    }
355
356    /// Convert to XYZ color space
357    pub fn to_xyz(&self) -> [f64; 3] {
358        self.color_space.lab_to_xyz(self.l, self.a, self.b)
359    }
360
361    /// Convert to approximate RGB for display
362    pub fn to_rgb(&self) -> [f64; 3] {
363        self.color_space.lab_to_rgb(self.l, self.a, self.b)
364    }
365
366    /// Calculate color difference from another Lab color
367    pub fn delta_e(&self, other: &LabColor) -> f64 {
368        self.color_space
369            .delta_e([self.l, self.a, self.b], [other.l, other.a, other.b])
370    }
371
372    /// Calculate perceptual color difference (Delta E 2000)
373    pub fn delta_e_2000(&self, other: &LabColor) -> f64 {
374        self.color_space
375            .delta_e_2000([self.l, self.a, self.b], [other.l, other.a, other.b])
376    }
377}
378
379/// Common Lab colors
380impl LabColor {
381    /// Pure white (L*=100)
382    pub fn white() -> Self {
383        Self::with_default(100.0, 0.0, 0.0)
384    }
385
386    /// Pure black (L*=0)
387    pub fn black() -> Self {
388        Self::with_default(0.0, 0.0, 0.0)
389    }
390
391    /// Middle gray (L*=50)
392    pub fn gray() -> Self {
393        Self::with_default(50.0, 0.0, 0.0)
394    }
395
396    /// Red
397    pub fn red() -> Self {
398        Self::with_default(53.0, 80.0, 67.0)
399    }
400
401    /// Green
402    pub fn green() -> Self {
403        Self::with_default(87.0, -86.0, 83.0)
404    }
405
406    /// Blue
407    pub fn blue() -> Self {
408        Self::with_default(32.0, 79.0, -108.0)
409    }
410
411    /// Yellow
412    pub fn yellow() -> Self {
413        Self::with_default(97.0, -22.0, 94.0)
414    }
415
416    /// Cyan
417    pub fn cyan() -> Self {
418        Self::with_default(91.0, -48.0, -14.0)
419    }
420
421    /// Magenta
422    pub fn magenta() -> Self {
423        Self::with_default(60.0, 98.0, -61.0)
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_lab_default() {
433        let cs = LabColorSpace::new();
434
435        assert_eq!(cs.white_point, [0.9505, 1.0000, 1.0890]);
436        assert_eq!(cs.black_point, [0.0, 0.0, 0.0]);
437        assert_eq!(cs.range, [-100.0, 100.0, -100.0, 100.0]);
438    }
439
440    #[test]
441    fn test_lab_custom() {
442        let cs = LabColorSpace::new()
443            .with_white_point([0.95, 1.0, 1.09])
444            .with_black_point([0.01, 0.01, 0.01])
445            .with_range(-128.0, 127.0, -128.0, 127.0);
446
447        assert_eq!(cs.white_point, [0.95, 1.0, 1.09]);
448        assert_eq!(cs.black_point, [0.01, 0.01, 0.01]);
449        assert_eq!(cs.range, [-128.0, 127.0, -128.0, 127.0]);
450    }
451
452    #[test]
453    fn test_lab_to_pdf() {
454        let cs = LabColorSpace::new()
455            .with_range(-128.0, 127.0, -128.0, 127.0)
456            .with_black_point([0.01, 0.01, 0.01]);
457
458        let pdf_array = cs.to_pdf_array();
459
460        assert_eq!(pdf_array.len(), 2);
461        assert_eq!(pdf_array[0], Object::Name("Lab".to_string()));
462
463        if let Object::Dictionary(dict) = &pdf_array[1] {
464            assert!(dict.get("WhitePoint").is_some());
465            assert!(dict.get("Range").is_some());
466            assert!(dict.get("BlackPoint").is_some());
467        } else {
468            panic!("Second element should be a dictionary");
469        }
470    }
471
472    #[test]
473    fn test_lab_color_creation() {
474        let color = LabColor::with_default(50.0, 25.0, -25.0);
475
476        assert_eq!(color.l, 50.0);
477        assert_eq!(color.a, 25.0);
478        assert_eq!(color.b, -25.0);
479    }
480
481    #[test]
482    fn test_lab_color_clamping() {
483        let color = LabColor::with_default(150.0, 200.0, -200.0);
484
485        assert_eq!(color.l, 100.0); // Clamped to max
486        assert_eq!(color.a, 100.0); // Clamped to range max
487        assert_eq!(color.b, -100.0); // Clamped to range min
488    }
489
490    #[test]
491    fn test_lab_to_xyz_conversion() {
492        let cs = LabColorSpace::new();
493        let [_x, y, _z] = cs.lab_to_xyz(50.0, 0.0, 0.0);
494
495        // Middle gray should have Y around 0.18
496        assert!((y - 0.184).abs() < 0.01);
497    }
498
499    #[test]
500    fn test_xyz_to_lab_conversion() {
501        let cs = LabColorSpace::new();
502        let original_lab = [50.0, 25.0, -25.0];
503        let xyz = cs.lab_to_xyz(original_lab[0], original_lab[1], original_lab[2]);
504        let converted_lab = cs.xyz_to_lab(xyz[0], xyz[1], xyz[2]);
505
506        // Should round-trip with minimal error
507        assert!((original_lab[0] - converted_lab[0]).abs() < 0.1);
508        assert!((original_lab[1] - converted_lab[1]).abs() < 0.1);
509        assert!((original_lab[2] - converted_lab[2]).abs() < 0.1);
510    }
511
512    #[test]
513    fn test_lab_to_rgb_approximation() {
514        let cs = LabColorSpace::new();
515
516        // Test white
517        let rgb_white = cs.lab_to_rgb(100.0, 0.0, 0.0);
518        assert!(rgb_white[0] > 0.99);
519        assert!(rgb_white[1] > 0.99);
520        assert!(rgb_white[2] > 0.99);
521
522        // Test black
523        let rgb_black = cs.lab_to_rgb(0.0, 0.0, 0.0);
524        assert!(rgb_black[0] < 0.01);
525        assert!(rgb_black[1] < 0.01);
526        assert!(rgb_black[2] < 0.01);
527    }
528
529    #[test]
530    fn test_delta_e() {
531        let cs = LabColorSpace::new();
532        let lab1 = [50.0, 0.0, 0.0];
533        let lab2 = [55.0, 0.0, 0.0];
534
535        let delta = cs.delta_e(lab1, lab2);
536        assert_eq!(delta, 5.0); // Pure L* difference
537    }
538
539    #[test]
540    fn test_common_colors() {
541        let white = LabColor::white();
542        assert_eq!(white.l, 100.0);
543
544        let black = LabColor::black();
545        assert_eq!(black.l, 0.0);
546
547        let gray = LabColor::gray();
548        assert_eq!(gray.l, 50.0);
549    }
550
551    #[test]
552    fn test_d65_illuminant() {
553        let cs = LabColorSpace::d65();
554        assert_eq!(cs.white_point, [0.9504, 1.0000, 1.0888]);
555    }
556
557    #[test]
558    fn test_color_values_normalization() {
559        let cs = LabColorSpace::new().with_range(-128.0, 127.0, -128.0, 127.0);
560        let color = LabColor::new(50.0, 0.0, 0.0, cs);
561
562        let values = color.values();
563        assert_eq!(values[0], 0.5); // L normalized to 0-1
564        assert!((values[1] - 0.5).abs() < 0.01); // a normalized around 0.5
565        assert!((values[2] - 0.5).abs() < 0.01); // b normalized around 0.5
566    }
567
568    #[test]
569    fn test_rgb_to_lab_conversion() {
570        let cs = LabColorSpace::new();
571
572        // Test with middle gray
573        let lab = cs.rgb_to_lab(0.5, 0.5, 0.5);
574        assert!((lab[0] - 53.0).abs() < 2.0); // Approximate L* for middle gray
575        assert!(lab[1].abs() < 1.0); // Should be near neutral
576        assert!(lab[2].abs() < 1.0); // Should be near neutral
577    }
578}