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