Skip to main content

stet_graphics/
color.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Color types and CIE color space parameters.
6
7/// Line cap style.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum LineCap {
11    #[default]
12    Butt = 0,
13    Round = 1,
14    Square = 2,
15}
16
17impl LineCap {
18    pub fn from_i32(v: i32) -> Option<Self> {
19        match v {
20            0 => Some(Self::Butt),
21            1 => Some(Self::Round),
22            2 => Some(Self::Square),
23            _ => None,
24        }
25    }
26}
27
28/// Line join style.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum LineJoin {
32    #[default]
33    Miter = 0,
34    Round = 1,
35    Bevel = 2,
36}
37
38impl LineJoin {
39    pub fn from_i32(v: i32) -> Option<Self> {
40        match v {
41            0 => Some(Self::Miter),
42            1 => Some(Self::Round),
43            2 => Some(Self::Bevel),
44            _ => None,
45        }
46    }
47}
48
49/// Fill rule for path filling.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum FillRule {
53    #[default]
54    NonZeroWinding,
55    EvenOdd,
56}
57
58/// Dash pattern for stroked paths.
59#[derive(Clone, Debug)]
60pub struct DashPattern {
61    pub array: Vec<f64>,
62    pub offset: f64,
63}
64
65impl DashPattern {
66    pub fn solid() -> Self {
67        Self {
68            array: Vec::new(),
69            offset: 0.0,
70        }
71    }
72}
73
74impl Default for DashPattern {
75    fn default() -> Self {
76        Self::solid()
77    }
78}
79
80/// Device color in RGB (internal representation) with optional native CMYK.
81#[derive(Clone, Debug)]
82pub struct DeviceColor {
83    pub r: f64,
84    pub g: f64,
85    pub b: f64,
86    /// Native CMYK components for lossless roundtrip when color space is CMYK.
87    /// For DeviceN/Separation paints this is the *full* alt-CMYK tint transform
88    /// (process + spot contributions combined).
89    pub native_cmyk: Option<(f64, f64, f64, f64)>,
90    /// Process-colorant-only CMYK contribution. Populated by DeviceN/Separation
91    /// paints so the overprint tracker can record only what actually lands on
92    /// process plates (C/M/Y/K), leaving any spot colorant's alt-CMYK out of
93    /// the process buffer. `None` means "use `native_cmyk` as the process
94    /// contribution" (pure DeviceCMYK / DeviceGray / DeviceRGB).
95    pub process_cmyk: Option<(f64, f64, f64, f64)>,
96}
97
98impl DeviceColor {
99    pub fn from_gray(gray: f64) -> Self {
100        Self {
101            r: gray,
102            g: gray,
103            b: gray,
104            native_cmyk: None,
105            process_cmyk: None,
106        }
107    }
108
109    pub fn from_rgb(r: f64, g: f64, b: f64) -> Self {
110        Self {
111            r,
112            g,
113            b,
114            native_cmyk: None,
115            process_cmyk: None,
116        }
117    }
118
119    pub fn from_cmyk(c: f64, m: f64, y: f64, k: f64) -> Self {
120        Self {
121            r: 1.0 - (c + k).min(1.0),
122            g: 1.0 - (m + k).min(1.0),
123            b: 1.0 - (y + k).min(1.0),
124            native_cmyk: Some((c, m, y, k)),
125            process_cmyk: None,
126        }
127    }
128
129    /// Create from CMYK, converting through ICC profile if available.
130    /// Falls back to PLRM formula when ICC is unavailable.
131    pub fn from_cmyk_icc(c: f64, m: f64, y: f64, k: f64, icc: &mut crate::icc::IccCache) -> Self {
132        if let Some((r, g, b)) = icc.convert_cmyk(c, m, y, k) {
133            Self {
134                r,
135                g,
136                b,
137                native_cmyk: Some((c, m, y, k)),
138                process_cmyk: None,
139            }
140        } else {
141            Self::from_cmyk(c, m, y, k)
142        }
143    }
144
145    pub fn from_hsb(h: f64, s: f64, b: f64) -> Self {
146        if s == 0.0 {
147            return Self::from_gray(b);
148        }
149        if b == 0.0 {
150            return Self::from_gray(0.0);
151        }
152
153        let mut hue = h * 6.0;
154        if hue >= 6.0 {
155            hue = 0.0;
156        }
157
158        let sector = hue as i32;
159        let frac = hue - sector as f64;
160
161        let p = b * (1.0 - s);
162        let q = b * (1.0 - s * frac);
163        let t = b * (1.0 - s * (1.0 - frac));
164
165        let (r, g, bl) = match sector {
166            0 => (b, t, p),
167            1 => (q, b, p),
168            2 => (p, b, t),
169            3 => (p, q, b),
170            4 => (t, p, b),
171            _ => (b, p, q),
172        };
173
174        Self {
175            r,
176            g,
177            b: bl,
178            native_cmyk: None,
179            process_cmyk: None,
180        }
181    }
182
183    /// Convert to gray using NTSC luma.
184    pub fn to_gray(&self) -> f64 {
185        0.3 * self.r + 0.59 * self.g + 0.11 * self.b
186    }
187
188    /// Convert to CMYK (uses native CMYK if available for lossless roundtrip).
189    pub fn to_cmyk(&self) -> (f64, f64, f64, f64) {
190        if let Some(cmyk) = self.native_cmyk {
191            return cmyk;
192        }
193        let c = 1.0 - self.r;
194        let m = 1.0 - self.g;
195        let y = 1.0 - self.b;
196        let k = c.min(m).min(y);
197        (
198            (c - k).clamp(0.0, 1.0),
199            (m - k).clamp(0.0, 1.0),
200            (y - k).clamp(0.0, 1.0),
201            k.clamp(0.0, 1.0),
202        )
203    }
204
205    /// Convert to HSB.
206    pub fn to_hsb(&self) -> (f64, f64, f64) {
207        let max_val = self.r.max(self.g).max(self.b);
208        let min_val = self.r.min(self.g).min(self.b);
209        let diff = max_val - min_val;
210
211        let brightness = max_val;
212        let saturation = if max_val == 0.0 { 0.0 } else { diff / max_val };
213
214        let hue = if diff == 0.0 {
215            0.0
216        } else if max_val == self.r {
217            let mut h = (self.g - self.b) / diff;
218            if h < 0.0 {
219                h += 6.0;
220            }
221            h / 6.0
222        } else if max_val == self.g {
223            ((self.b - self.r) / diff + 2.0) / 6.0
224        } else {
225            ((self.r - self.g) / diff + 4.0) / 6.0
226        };
227
228        (hue, saturation, brightness)
229    }
230
231    /// Black (default color).
232    pub fn black() -> Self {
233        Self {
234            r: 0.0,
235            g: 0.0,
236            b: 0.0,
237            native_cmyk: None,
238            process_cmyk: None,
239        }
240    }
241
242    /// Apply sRGB gamma companding (linear → gamma-corrected).
243    fn srgb_gamma(u: f64) -> f64 {
244        if u <= 0.0031308 {
245            12.92 * u
246        } else {
247            1.055 * u.powf(1.0 / 2.4) - 0.055
248        }
249    }
250
251    /// Convert CIE XYZ (D65-adapted) to sRGB.
252    fn from_xyz(x: f64, y: f64, z: f64) -> Self {
253        // IEC 61966-2-1 sRGB D65 XYZ → linear RGB matrix
254        let lr = 3.2404542 * x + (-1.5371385) * y + (-0.4985314) * z;
255        let lg = (-0.9692660) * x + 1.8760108 * y + 0.0415560 * z;
256        let lb = 0.0556434 * x + (-0.2040259) * y + 1.0572252 * z;
257
258        Self {
259            r: Self::srgb_gamma(lr.max(0.0)).clamp(0.0, 1.0),
260            g: Self::srgb_gamma(lg.max(0.0)).clamp(0.0, 1.0),
261            b: Self::srgb_gamma(lb.max(0.0)).clamp(0.0, 1.0),
262            native_cmyk: None,
263            process_cmyk: None,
264        }
265    }
266
267    /// Bradford chromatic adaptation: adapt XYZ from source white point to D65.
268    fn adapt_xyz_to_d65(x: f64, y: f64, z: f64, src_wp: &[f64; 3]) -> [f64; 3] {
269        // D65 white point (sRGB standard illuminant)
270        const D65: [f64; 3] = [0.95047, 1.0, 1.08883];
271
272        // Skip adaptation if source is already D65
273        if (src_wp[0] - D65[0]).abs() < 1e-3
274            && (src_wp[1] - D65[1]).abs() < 1e-3
275            && (src_wp[2] - D65[2]).abs() < 1e-3
276        {
277            return [x, y, z];
278        }
279
280        // Bradford matrix (XYZ → LMS cone space), column-major
281        const M: [f64; 9] = [
282            0.8951, -0.7502, 0.0389, 0.2664, 1.7135, -0.0685, -0.1614, 0.0367, 1.0296,
283        ];
284        // Inverse Bradford matrix (LMS → XYZ), column-major
285        const M_INV: [f64; 9] = [
286            0.9869929, 0.4323053, -0.0085287, -0.1470543, 0.5183603, 0.0400428, 0.1599627,
287            0.0492912, 0.9684867,
288        ];
289
290        // Convert source and D65 white points to LMS
291        let lms_src = Self::apply_matrix_3x3(&M, src_wp);
292        let lms_d65 = Self::apply_matrix_3x3(&M, &D65);
293
294        // Diagonal scaling in LMS space
295        let s0 = if lms_src[0].abs() > 1e-10 {
296            lms_d65[0] / lms_src[0]
297        } else {
298            1.0
299        };
300        let s1 = if lms_src[1].abs() > 1e-10 {
301            lms_d65[1] / lms_src[1]
302        } else {
303            1.0
304        };
305        let s2 = if lms_src[2].abs() > 1e-10 {
306            lms_d65[2] / lms_src[2]
307        } else {
308            1.0
309        };
310
311        // Adapt: M_inv × diag(s) × M × [x, y, z]
312        let lms = Self::apply_matrix_3x3(&M, &[x, y, z]);
313        let scaled = [lms[0] * s0, lms[1] * s1, lms[2] * s2];
314        Self::apply_matrix_3x3(&M_INV, &scaled)
315    }
316
317    /// Apply a column-major 3×3 matrix to a 3-element vector.
318    fn apply_matrix_3x3(mat: &[f64; 9], v: &[f64; 3]) -> [f64; 3] {
319        [
320            mat[0] * v[0] + mat[3] * v[1] + mat[6] * v[2],
321            mat[1] * v[0] + mat[4] * v[1] + mat[7] * v[2],
322            mat[2] * v[0] + mat[5] * v[1] + mat[8] * v[2],
323        ]
324    }
325
326    /// Linear interpolation in a pre-evaluated decode table.
327    fn decode_lookup(table: &[f64], value: f64) -> f64 {
328        let n = table.len();
329        if n < 2 {
330            return table.first().copied().unwrap_or(value);
331        }
332        let idx = value * (n - 1) as f64;
333        let i0 = (idx as usize).min(n - 2);
334        let frac = idx - i0 as f64;
335        table[i0] + (table[i0 + 1] - table[i0]) * frac
336    }
337
338    /// Convert CIE L\*a\*b\* to sRGB.
339    ///
340    /// PDF Lab color spaces specify a white point, but Lab is perceptually
341    /// uniform — the same (L\*, a\*, b\*) coordinates represent the same
342    /// perceived color regardless of the declared white point.  We convert
343    /// directly through D65 (the sRGB reference illuminant), which makes
344    /// the specified white point irrelevant and avoids Bradford adaptation
345    /// errors for extreme/non-physical white points.
346    ///
347    /// `range` is the a\*/b\* clamp range: [a_min, a_max, b_min, b_max].
348    pub fn from_lab(l_star: f64, a_star: f64, b_star: f64, range: &[f64; 4]) -> Self {
349        // D65 white point (sRGB reference illuminant)
350        const D65: [f64; 3] = [0.95047, 1.0, 1.08883];
351
352        let l_star = l_star.clamp(0.0, 100.0);
353        let a_star = a_star.clamp(range[0], range[1]);
354        let b_star = b_star.clamp(range[2], range[3]);
355
356        let fy = (l_star + 16.0) / 116.0;
357        let fx = a_star / 500.0 + fy;
358        let fz = fy - b_star / 200.0;
359
360        let x = D65[0] * Self::lab_f_inv(fx);
361        let y = D65[1] * Self::lab_f_inv(fy);
362        let z = D65[2] * Self::lab_f_inv(fz);
363
364        Self::from_xyz(x, y, z)
365    }
366
367    fn lab_f_inv(t: f64) -> f64 {
368        if t > 6.0 / 29.0 {
369            t * t * t
370        } else {
371            3.0 * (6.0 / 29.0) * (6.0 / 29.0) * (t - 4.0 / 29.0)
372        }
373    }
374
375    /// Convert CIEBasedABC color to sRGB.
376    pub fn from_cie_abc(a: f64, b: f64, c: f64, params: &CieAbcParams) -> Self {
377        let mut a = a.clamp(params.range_abc[0], params.range_abc[1]);
378        let mut b = b.clamp(params.range_abc[2], params.range_abc[3]);
379        let mut c = c.clamp(params.range_abc[4], params.range_abc[5]);
380
381        if let Some(ref tables) = params.decode_abc {
382            let ra = params.range_abc[1] - params.range_abc[0];
383            let rb = params.range_abc[3] - params.range_abc[2];
384            let rc = params.range_abc[5] - params.range_abc[4];
385            let na = if ra > 0.0 {
386                (a - params.range_abc[0]) / ra
387            } else {
388                0.0
389            };
390            let nb = if rb > 0.0 {
391                (b - params.range_abc[2]) / rb
392            } else {
393                0.0
394            };
395            let nc = if rc > 0.0 {
396                (c - params.range_abc[4]) / rc
397            } else {
398                0.0
399            };
400            a = Self::decode_lookup(&tables[0], na);
401            b = Self::decode_lookup(&tables[1], nb);
402            c = Self::decode_lookup(&tables[2], nc);
403        }
404
405        let lmn = Self::apply_matrix_3x3(&params.matrix_abc, &[a, b, c]);
406
407        let mut l = lmn[0].clamp(params.range_lmn[0], params.range_lmn[1]);
408        let mut m = lmn[1].clamp(params.range_lmn[2], params.range_lmn[3]);
409        let mut n = lmn[2].clamp(params.range_lmn[4], params.range_lmn[5]);
410
411        if let Some(ref tables) = params.decode_lmn {
412            let rl = params.range_lmn[1] - params.range_lmn[0];
413            let rm = params.range_lmn[3] - params.range_lmn[2];
414            let rn = params.range_lmn[5] - params.range_lmn[4];
415            let nl = if rl > 0.0 {
416                (l - params.range_lmn[0]) / rl
417            } else {
418                0.0
419            };
420            let nm = if rm > 0.0 {
421                (m - params.range_lmn[2]) / rm
422            } else {
423                0.0
424            };
425            let nn = if rn > 0.0 {
426                (n - params.range_lmn[4]) / rn
427            } else {
428                0.0
429            };
430            l = Self::decode_lookup(&tables[0], nl);
431            m = Self::decode_lookup(&tables[1], nm);
432            n = Self::decode_lookup(&tables[2], nn);
433        }
434
435        let xyz = Self::apply_matrix_3x3(&params.matrix_lmn, &[l, m, n]);
436
437        // Chromatic adaptation from source white point to D65
438        let xyz = Self::adapt_xyz_to_d65(xyz[0], xyz[1], xyz[2], &params.white_point);
439        Self::from_xyz(xyz[0], xyz[1], xyz[2])
440    }
441
442    /// Convert CIEBasedA color to sRGB.
443    pub fn from_cie_a(a: f64, params: &CieAParams) -> Self {
444        let mut a = a.clamp(params.range_a[0], params.range_a[1]);
445
446        if let Some(ref table) = params.decode_a {
447            let ra = params.range_a[1] - params.range_a[0];
448            let na = if ra > 0.0 {
449                (a - params.range_a[0]) / ra
450            } else {
451                0.0
452            };
453            a = Self::decode_lookup(table, na);
454        }
455
456        let lmn = [
457            params.matrix_a[0] * a,
458            params.matrix_a[1] * a,
459            params.matrix_a[2] * a,
460        ];
461
462        let mut l = lmn[0].clamp(params.range_lmn[0], params.range_lmn[1]);
463        let mut m = lmn[1].clamp(params.range_lmn[2], params.range_lmn[3]);
464        let mut n = lmn[2].clamp(params.range_lmn[4], params.range_lmn[5]);
465
466        if let Some(ref tables) = params.decode_lmn {
467            let rl = params.range_lmn[1] - params.range_lmn[0];
468            let rm = params.range_lmn[3] - params.range_lmn[2];
469            let rn = params.range_lmn[5] - params.range_lmn[4];
470            let nl = if rl > 0.0 {
471                (l - params.range_lmn[0]) / rl
472            } else {
473                0.0
474            };
475            let nm = if rm > 0.0 {
476                (m - params.range_lmn[2]) / rm
477            } else {
478                0.0
479            };
480            let nn = if rn > 0.0 {
481                (n - params.range_lmn[4]) / rn
482            } else {
483                0.0
484            };
485            l = Self::decode_lookup(&tables[0], nl);
486            m = Self::decode_lookup(&tables[1], nm);
487            n = Self::decode_lookup(&tables[2], nn);
488        }
489
490        let xyz = Self::apply_matrix_3x3(&params.matrix_lmn, &[l, m, n]);
491
492        // Chromatic adaptation from source white point to D65
493        let xyz = Self::adapt_xyz_to_d65(xyz[0], xyz[1], xyz[2], &params.white_point);
494        Self::from_xyz(xyz[0], xyz[1], xyz[2])
495    }
496
497    /// Convert CIEBasedDEF color to sRGB via pre-converted trilinear interpolation table.
498    pub fn from_cie_def(d: f64, e: f64, f: f64, params: &CieDefParams) -> Self {
499        let (m1, m2, m3) = (params.m1, params.m2, params.m3);
500        if m1 < 2 || m2 < 2 || m3 < 2 {
501            return Self::from_gray(0.0);
502        }
503
504        let d_range = params.range_def[1] - params.range_def[0];
505        let e_range = params.range_def[3] - params.range_def[2];
506        let f_range = params.range_def[5] - params.range_def[4];
507
508        let di = if d_range > 0.0 {
509            ((d - params.range_def[0]) / d_range * (m1 - 1) as f64).clamp(0.0, (m1 - 1) as f64)
510        } else {
511            0.0
512        };
513        let ei = if e_range > 0.0 {
514            ((e - params.range_def[2]) / e_range * (m2 - 1) as f64).clamp(0.0, (m2 - 1) as f64)
515        } else {
516            0.0
517        };
518        let fi = if f_range > 0.0 {
519            ((f - params.range_def[4]) / f_range * (m3 - 1) as f64).clamp(0.0, (m3 - 1) as f64)
520        } else {
521            0.0
522        };
523
524        let di0 = (di as usize).min(m1 - 2);
525        let ei0 = (ei as usize).min(m2 - 2);
526        let fi0 = (fi as usize).min(m3 - 2);
527        let di1 = di0 + 1;
528        let ei1 = ei0 + 1;
529        let fi1 = fi0 + 1;
530        let dd = di - di0 as f64;
531        let de = ei - ei0 as f64;
532        let df = fi - fi0 as f64;
533
534        let stride_e = m3;
535        let stride_d = m2 * m3;
536
537        let mut abc = [0.0f64; 3];
538        for (ch, table) in [&params.a_table, &params.b_table, &params.c_table]
539            .iter()
540            .enumerate()
541        {
542            let c000 = table[di0 * stride_d + ei0 * stride_e + fi0];
543            let c001 = table[di0 * stride_d + ei0 * stride_e + fi1];
544            let c010 = table[di0 * stride_d + ei1 * stride_e + fi0];
545            let c011 = table[di0 * stride_d + ei1 * stride_e + fi1];
546            let c100 = table[di1 * stride_d + ei0 * stride_e + fi0];
547            let c101 = table[di1 * stride_d + ei0 * stride_e + fi1];
548            let c110 = table[di1 * stride_d + ei1 * stride_e + fi0];
549            let c111 = table[di1 * stride_d + ei1 * stride_e + fi1];
550
551            let c00 = c000 * (1.0 - df) + c001 * df;
552            let c01 = c010 * (1.0 - df) + c011 * df;
553            let c10 = c100 * (1.0 - df) + c101 * df;
554            let c11 = c110 * (1.0 - df) + c111 * df;
555
556            let c0 = c00 * (1.0 - de) + c01 * de;
557            let c1 = c10 * (1.0 - de) + c11 * de;
558
559            abc[ch] = c0 * (1.0 - dd) + c1 * dd;
560        }
561
562        Self::from_cie_abc(abc[0], abc[1], abc[2], &params.abc_params)
563    }
564
565    /// Convert CIEBasedDEFG color to sRGB via pre-converted nearest-neighbor 4D table.
566    pub fn from_cie_defg(d: f64, e: f64, f: f64, g: f64, params: &CieDefgParams) -> Self {
567        let (m1, m2, m3, m4) = (params.m1, params.m2, params.m3, params.m4);
568        if m1 == 0 || m2 == 0 || m3 == 0 || m4 == 0 {
569            return Self::from_gray(0.0);
570        }
571
572        let d_range = params.range_defg[1] - params.range_defg[0];
573        let e_range = params.range_defg[3] - params.range_defg[2];
574        let f_range = params.range_defg[5] - params.range_defg[4];
575        let g_range = params.range_defg[7] - params.range_defg[6];
576
577        let di = if d_range > 0.0 {
578            ((d - params.range_defg[0]) / d_range * (m1 - 1) as f64 + 0.5) as usize
579        } else {
580            0
581        }
582        .min(m1 - 1);
583        let ei = if e_range > 0.0 {
584            ((e - params.range_defg[2]) / e_range * (m2 - 1) as f64 + 0.5) as usize
585        } else {
586            0
587        }
588        .min(m2 - 1);
589        let fi = if f_range > 0.0 {
590            ((f - params.range_defg[4]) / f_range * (m3 - 1) as f64 + 0.5) as usize
591        } else {
592            0
593        }
594        .min(m3 - 1);
595        let gi = if g_range > 0.0 {
596            ((g - params.range_defg[6]) / g_range * (m4 - 1) as f64 + 0.5) as usize
597        } else {
598            0
599        }
600        .min(m4 - 1);
601
602        let idx = di * m2 * m3 * m4 + ei * m3 * m4 + fi * m4 + gi;
603        if idx >= params.a_table.len() {
604            return Self::from_gray(0.0);
605        }
606
607        Self::from_cie_abc(
608            params.a_table[idx],
609            params.b_table[idx],
610            params.c_table[idx],
611            &params.abc_params,
612        )
613    }
614}
615
616impl Default for DeviceColor {
617    fn default() -> Self {
618        Self::black()
619    }
620}
621
622/// Extracted parameters for CIEBasedABC color conversion.
623#[derive(Clone, Debug)]
624pub struct CieAbcParams {
625    pub range_abc: [f64; 6],
626    pub decode_abc: Option<[Vec<f64>; 3]>,
627    pub matrix_abc: [f64; 9],
628    pub range_lmn: [f64; 6],
629    pub decode_lmn: Option<[Vec<f64>; 3]>,
630    pub matrix_lmn: [f64; 9],
631    pub white_point: [f64; 3],
632}
633
634impl Default for CieAbcParams {
635    fn default() -> Self {
636        Self {
637            range_abc: [0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
638            decode_abc: None,
639            matrix_abc: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
640            range_lmn: [0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
641            decode_lmn: None,
642            matrix_lmn: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
643            white_point: [0.9505, 1.0, 1.089],
644        }
645    }
646}
647
648/// Extracted parameters for CIEBasedA color conversion.
649#[derive(Clone, Debug)]
650pub struct CieAParams {
651    pub range_a: [f64; 2],
652    pub decode_a: Option<Vec<f64>>,
653    pub matrix_a: [f64; 3],
654    pub range_lmn: [f64; 6],
655    pub decode_lmn: Option<[Vec<f64>; 3]>,
656    pub matrix_lmn: [f64; 9],
657    pub white_point: [f64; 3],
658}
659
660impl Default for CieAParams {
661    fn default() -> Self {
662        Self {
663            range_a: [0.0, 1.0],
664            decode_a: None,
665            matrix_a: [1.0, 1.0, 1.0],
666            range_lmn: [0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
667            decode_lmn: None,
668            matrix_lmn: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
669            white_point: [0.9505, 1.0, 1.089],
670        }
671    }
672}
673
674/// Pre-converted parameters for CIEBasedDEF color space (3D table → RGB).
675#[derive(Clone, Debug)]
676pub struct CieDefParams {
677    pub range_def: [f64; 6],
678    pub m1: usize,
679    pub m2: usize,
680    pub m3: usize,
681    pub a_table: Vec<f64>,
682    pub b_table: Vec<f64>,
683    pub c_table: Vec<f64>,
684    pub abc_params: CieAbcParams,
685}
686
687/// Parameters for CIEBasedDEFG color space (4D table → ABC → CIE pipeline).
688#[derive(Clone, Debug)]
689pub struct CieDefgParams {
690    pub range_defg: [f64; 8],
691    pub m1: usize,
692    pub m2: usize,
693    pub m3: usize,
694    pub m4: usize,
695    pub a_table: Vec<f64>,
696    pub b_table: Vec<f64>,
697    pub c_table: Vec<f64>,
698    pub abc_params: CieAbcParams,
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn test_color_from_gray() {
707        let c = DeviceColor::from_gray(0.5);
708        assert!((c.r - 0.5).abs() < 1e-10);
709        assert!((c.g - 0.5).abs() < 1e-10);
710        assert!((c.b - 0.5).abs() < 1e-10);
711    }
712
713    #[test]
714    fn test_color_from_cmyk() {
715        let c = DeviceColor::from_cmyk(1.0, 0.0, 0.0, 0.0);
716        assert!((c.r - 0.0).abs() < 1e-10);
717        assert!((c.g - 1.0).abs() < 1e-10);
718        assert!((c.b - 1.0).abs() < 1e-10);
719    }
720
721    #[test]
722    fn test_color_from_hsb() {
723        let c = DeviceColor::from_hsb(0.0, 1.0, 1.0);
724        assert!((c.r - 1.0).abs() < 1e-10);
725        assert!((c.g - 0.0).abs() < 1e-10);
726        assert!((c.b - 0.0).abs() < 1e-10);
727
728        let c = DeviceColor::from_hsb(1.0 / 3.0, 1.0, 1.0);
729        assert!((c.r - 0.0).abs() < 1e-10);
730        assert!((c.g - 1.0).abs() < 1e-10);
731        assert!((c.b - 0.0).abs() < 1e-10);
732    }
733
734    #[test]
735    fn test_color_gray_roundtrip() {
736        let c = DeviceColor::from_gray(0.5);
737        let gray = c.to_gray();
738        assert!((gray - 0.5).abs() < 1e-10);
739    }
740
741    #[test]
742    fn test_color_hsb_roundtrip() {
743        let c = DeviceColor::from_hsb(0.6, 0.8, 0.9);
744        let (h, s, b) = c.to_hsb();
745        assert!((h - 0.6).abs() < 0.01);
746        assert!((s - 0.8).abs() < 0.01);
747        assert!((b - 0.9).abs() < 0.01);
748    }
749
750    #[test]
751    fn test_linecap_from_i32() {
752        assert_eq!(LineCap::from_i32(0), Some(LineCap::Butt));
753        assert_eq!(LineCap::from_i32(1), Some(LineCap::Round));
754        assert_eq!(LineCap::from_i32(2), Some(LineCap::Square));
755        assert_eq!(LineCap::from_i32(3), None);
756    }
757
758    #[test]
759    fn test_linejoin_from_i32() {
760        assert_eq!(LineJoin::from_i32(0), Some(LineJoin::Miter));
761        assert_eq!(LineJoin::from_i32(1), Some(LineJoin::Round));
762        assert_eq!(LineJoin::from_i32(2), Some(LineJoin::Bevel));
763        assert_eq!(LineJoin::from_i32(3), None);
764    }
765}