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