Skip to main content

rpdfium_render/
color_convert.rs

1// Derived from PDFium's core/fpdfapi/render/cpdf_renderstatus.cpp color conversion
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Color space conversion from PDF color values to device RGBA.
7//!
8//! Supports device color spaces (Gray, RGB, CMYK) and advanced calibrated
9//! color spaces (CalGray, CalRGB, Lab, ICCBased, Indexed, Separation, DeviceN).
10
11use rpdfium_graphics::Color;
12use rpdfium_page::color_space::{ColorSpaceParams, ResolvedColorSpace};
13use rpdfium_page::function;
14
15/// An RGBA color value with 8 bits per component.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub struct RgbaColor {
18    /// Red component.
19    pub r: u8,
20    /// Green component.
21    pub g: u8,
22    /// Blue component.
23    pub b: u8,
24    /// Alpha component (255 = fully opaque).
25    pub a: u8,
26}
27
28impl RgbaColor {
29    /// Opaque white.
30    pub const WHITE: Self = Self {
31        r: 255,
32        g: 255,
33        b: 255,
34        a: 255,
35    };
36
37    /// Opaque black.
38    pub const BLACK: Self = Self {
39        r: 0,
40        g: 0,
41        b: 0,
42        a: 255,
43    };
44
45    /// Fully transparent.
46    pub const TRANSPARENT: Self = Self {
47        r: 0,
48        g: 0,
49        b: 0,
50        a: 0,
51    };
52
53    /// Create a new RGBA color.
54    pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
55        Self { r, g, b, a }
56    }
57
58    /// Convert a PDF color to RGBA.
59    ///
60    /// The `alpha` parameter is in the range [0.0, 1.0].
61    /// Color components are interpreted based on the number of components:
62    /// - 1 component: DeviceGray
63    /// - 3 components: DeviceRGB
64    /// - 4 components: DeviceCMYK (simple subtractive conversion)
65    pub fn from_pdf_color(color: &Color, alpha: f32) -> Self {
66        let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
67        match color.components.len() {
68            1 => {
69                let v = (color.components[0].clamp(0.0, 1.0) * 255.0).round() as u8;
70                Self {
71                    r: v,
72                    g: v,
73                    b: v,
74                    a,
75                }
76            }
77            3 => Self {
78                r: (color.components[0].clamp(0.0, 1.0) * 255.0).round() as u8,
79                g: (color.components[1].clamp(0.0, 1.0) * 255.0).round() as u8,
80                b: (color.components[2].clamp(0.0, 1.0) * 255.0).round() as u8,
81                a,
82            },
83            4 => {
84                let c = color.components[0].clamp(0.0, 1.0);
85                let m = color.components[1].clamp(0.0, 1.0);
86                let y_val = color.components[2].clamp(0.0, 1.0);
87                let k = color.components[3].clamp(0.0, 1.0);
88                let (r, g, b) = crate::cfx_cmyk_to_srgb::adobe_cmyk_f32_to_srgb(c, m, y_val, k);
89                Self { r, g, b, a }
90            }
91            _ => Self {
92                r: 0,
93                g: 0,
94                b: 0,
95                a,
96            },
97        }
98    }
99
100    /// Convert a color using a resolved color space to RGBA.
101    ///
102    /// This handles advanced color spaces (CalGray, CalRGB, Lab, ICCBased,
103    /// Indexed, Separation, DeviceN) by converting through intermediate
104    /// device-independent steps to sRGB.
105    pub fn from_resolved_color(color: &Color, cs: &ResolvedColorSpace, alpha: f32) -> Self {
106        let a_byte = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
107
108        match &cs.params {
109            ColorSpaceParams::Device => Self::from_pdf_color(color, alpha),
110
111            ColorSpaceParams::CalGray { gamma, .. } => {
112                let v = color
113                    .components
114                    .first()
115                    .copied()
116                    .unwrap_or(0.0)
117                    .clamp(0.0, 1.0);
118                let v_linear = v.powf(*gamma);
119                let c = (v_linear.clamp(0.0, 1.0) * 255.0).round() as u8;
120                Self {
121                    r: c,
122                    g: c,
123                    b: c,
124                    a: a_byte,
125                }
126            }
127
128            ColorSpaceParams::CalRGB {
129                gamma,
130                matrix,
131                white_point,
132            } => {
133                let r_in = color
134                    .components
135                    .first()
136                    .copied()
137                    .unwrap_or(0.0)
138                    .clamp(0.0, 1.0);
139                let g_in = color
140                    .components
141                    .get(1)
142                    .copied()
143                    .unwrap_or(0.0)
144                    .clamp(0.0, 1.0);
145                let b_in = color
146                    .components
147                    .get(2)
148                    .copied()
149                    .unwrap_or(0.0)
150                    .clamp(0.0, 1.0);
151
152                // Apply gamma
153                let a_lin = r_in.powf(gamma[0]);
154                let b_lin = g_in.powf(gamma[1]);
155                let c_lin = b_in.powf(gamma[2]);
156
157                // Apply matrix to get XYZ
158                let x = matrix[0] * a_lin + matrix[3] * b_lin + matrix[6] * c_lin;
159                let y = matrix[1] * a_lin + matrix[4] * b_lin + matrix[7] * c_lin;
160                let z = matrix[2] * a_lin + matrix[5] * b_lin + matrix[8] * c_lin;
161
162                // XYZ to sRGB
163                let (r_out, g_out, b_out) = xyz_to_srgb(x, y, z, white_point);
164                Self {
165                    r: (r_out * 255.0).round() as u8,
166                    g: (g_out * 255.0).round() as u8,
167                    b: (b_out * 255.0).round() as u8,
168                    a: a_byte,
169                }
170            }
171
172            ColorSpaceParams::Lab { range, .. } => {
173                let l_star = color
174                    .components
175                    .first()
176                    .copied()
177                    .unwrap_or(0.0)
178                    .clamp(0.0, 100.0);
179                let a_star = color
180                    .components
181                    .get(1)
182                    .copied()
183                    .unwrap_or(0.0)
184                    .clamp(range[0], range[1]);
185                let b_star = color
186                    .components
187                    .get(2)
188                    .copied()
189                    .unwrap_or(0.0)
190                    .clamp(range[2], range[3]);
191
192                // Lab→XYZ→sRGB matching upstream CPDF_LabCS::GetRGB exactly.
193                // Uses hardcoded D65 approximations (0.957, 1.0889) rather than
194                // the per-CS white point, matching upstream behavior.
195                let m = (l_star + 16.0) / 116.0;
196                let l = m + a_star / 500.0;
197                let n = m - b_star / 200.0;
198
199                let x = if l < 0.2069 {
200                    0.957 * 0.12842 * (l - 0.1379)
201                } else {
202                    0.957 * l * l * l
203                };
204                let y = if m < 0.2069 {
205                    0.12842 * (m - 0.1379)
206                } else {
207                    m * m * m
208                };
209                let z = if n < 0.2069 {
210                    1.0889 * 0.12842 * (n - 0.1379)
211                } else {
212                    1.0889 * n * n * n
213                };
214
215                let (r_out, g_out, b_out) = xyz_to_srgb(x, y, z, &[0.957, 1.0, 1.0889]);
216                Self {
217                    r: (r_out * 255.0).round() as u8,
218                    g: (g_out * 255.0).round() as u8,
219                    b: (b_out * 255.0).round() as u8,
220                    a: a_byte,
221                }
222            }
223
224            ColorSpaceParams::ICCBased {
225                n_components,
226                alternate,
227                icc_profile_data,
228            } => {
229                // Try ICC profile transform first, fall back to alternate
230                if let Some(profile_data) = icc_profile_data {
231                    if let Some((r, g, b)) = icc_transform(color, profile_data, *n_components) {
232                        return Self { r, g, b, a: a_byte };
233                    }
234                }
235                Self::from_resolved_color(color, alternate, alpha)
236            }
237
238            ColorSpaceParams::Indexed {
239                base,
240                hival,
241                lookup,
242            } => {
243                let index = color.components.first().copied().unwrap_or(0.0) as u16;
244                let index = index.min(*hival);
245                let n = base.component_count() as usize;
246                let start = index as usize * n;
247                // Map palette bytes from [0,255] to the base CS component ranges.
248                // For DeviceRGB this is [0,1]; for Lab it is [0,100]/[-128,127].
249                // Matches upstream CPDF_IndexedCS::TranslateColorInternal.
250                let ranges = base.component_ranges();
251                let mut components = Vec::with_capacity(n);
252                for i in 0..n {
253                    let byte = lookup.get(start + i).copied().unwrap_or(0) as f32;
254                    let (min_val, max_val) = ranges.get(i).copied().unwrap_or((0.0, 1.0));
255                    components.push(min_val + (byte / 255.0) * (max_val - min_val));
256                }
257                let base_color = Color { components };
258                Self::from_resolved_color(&base_color, base, alpha)
259            }
260
261            ColorSpaceParams::Separation {
262                alternate, tint_fn, ..
263            } => {
264                let tint = color.components.first().copied().unwrap_or(0.0);
265                let alt_components = function::evaluate(tint_fn, &[tint]);
266                let alt_color = Color {
267                    components: alt_components,
268                };
269                Self::from_resolved_color(&alt_color, alternate, alpha)
270            }
271
272            ColorSpaceParams::DeviceN {
273                alternate, tint_fn, ..
274            } => {
275                let alt_components = function::evaluate(tint_fn, &color.components);
276                let alt_color = Color {
277                    components: alt_components,
278                };
279                Self::from_resolved_color(&alt_color, alternate, alpha)
280            }
281        }
282    }
283}
284
285/// Attempt ICC profile-based color transform via moxcms.
286///
287/// Returns `(r, g, b)` in 8-bit sRGB, or `None` if the profile cannot be used.
288fn icc_transform(color: &Color, profile_data: &[u8], n_components: u8) -> Option<(u8, u8, u8)> {
289    use std::sync::Arc;
290
291    let src_profile = moxcms::ColorProfile::new_from_slice(profile_data).ok()?;
292    let dst_profile = moxcms::ColorProfile::new_srgb();
293    let src_layout = match n_components {
294        1 => moxcms::Layout::Gray,
295        3 => moxcms::Layout::Rgb,
296        // CMYK uses Rgba layout (4 channels) per moxcms convention
297        4 => moxcms::Layout::Rgba,
298        _ => return None,
299    };
300    let executor: Arc<moxcms::Transform8BitExecutor> = src_profile
301        .create_transform_8bit(
302            src_layout,
303            &dst_profile,
304            moxcms::Layout::Rgb,
305            moxcms::TransformOptions::default(),
306        )
307        .ok()?;
308    let src_bytes: Vec<u8> = color
309        .components
310        .iter()
311        .map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)
312        .collect();
313    let mut dst = [0u8; 3];
314    executor.transform(&src_bytes, &mut dst).ok()?;
315    Some((dst[0], dst[1], dst[2]))
316}
317
318// ---------------------------------------------------------------------------
319// Upstream-matching sRGB conversion via lookup table.
320// Ported from cpdf_colorspace.cpp: kSRGBSamples1, kSRGBSamples2, RGB_Conversion.
321// ---------------------------------------------------------------------------
322
323/// Upstream sRGB lookup table for low-range linear values (indices 0-191).
324#[rustfmt::skip]
325static SRGB_SAMPLES_1: [u8; 192] = [
326    0,   3,   6,   10,  13,  15,  18,  20,  22,  23,  25,  27,  28,  30,  31,
327    32,  34,  35,  36,  37,  38,  39,  40,  41,  42,  43,  44,  45,  46,  47,
328    48,  49,  49,  50,  51,  52,  53,  53,  54,  55,  56,  56,  57,  58,  58,
329    59,  60,  61,  61,  62,  62,  63,  64,  64,  65,  66,  66,  67,  67,  68,
330    68,  69,  70,  70,  71,  71,  72,  72,  73,  73,  74,  74,  75,  76,  76,
331    77,  77,  78,  78,  79,  79,  79,  80,  80,  81,  81,  82,  82,  83,  83,
332    84,  84,  85,  85,  85,  86,  86,  87,  87,  88,  88,  88,  89,  89,  90,
333    90,  91,  91,  91,  92,  92,  93,  93,  93,  94,  94,  95,  95,  95,  96,
334    96,  97,  97,  97,  98,  98,  98,  99,  99,  99,  100, 100, 101, 101, 101,
335    102, 102, 102, 103, 103, 103, 104, 104, 104, 105, 105, 106, 106, 106, 107,
336    107, 107, 108, 108, 108, 109, 109, 109, 110, 110, 110, 110, 111, 111, 111,
337    112, 112, 112, 113, 113, 113, 114, 114, 114, 115, 115, 115, 115, 116, 116,
338    116, 117, 117, 117, 118, 118, 118, 118, 119, 119, 119, 120,
339];
340
341/// Upstream sRGB lookup table for high-range linear values (indices 192+).
342#[rustfmt::skip]
343static SRGB_SAMPLES_2: [u8; 208] = [
344    120, 121, 122, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135,
345    136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 148, 149,
346    150, 151, 152, 153, 154, 155, 155, 156, 157, 158, 159, 159, 160, 161, 162,
347    163, 163, 164, 165, 166, 167, 167, 168, 169, 170, 170, 171, 172, 173, 173,
348    174, 175, 175, 176, 177, 178, 178, 179, 180, 180, 181, 182, 182, 183, 184,
349    185, 185, 186, 187, 187, 188, 189, 189, 190, 190, 191, 192, 192, 193, 194,
350    194, 195, 196, 196, 197, 197, 198, 199, 199, 200, 200, 201, 202, 202, 203,
351    203, 204, 205, 205, 206, 206, 207, 208, 208, 209, 209, 210, 210, 211, 212,
352    212, 213, 213, 214, 214, 215, 215, 216, 216, 217, 218, 218, 219, 219, 220,
353    220, 221, 221, 222, 222, 223, 223, 224, 224, 225, 226, 226, 227, 227, 228,
354    228, 229, 229, 230, 230, 231, 231, 232, 232, 233, 233, 234, 234, 235, 235,
355    236, 236, 237, 237, 238, 238, 238, 239, 239, 240, 240, 241, 241, 242, 242,
356    243, 243, 244, 244, 245, 245, 246, 246, 246, 247, 247, 248, 248, 249, 249,
357    250, 250, 251, 251, 251, 252, 252, 253, 253, 254, 254, 255, 255,
358];
359
360/// sRGB companding via lookup table, matching upstream `RGB_Conversion()`.
361fn rgb_conversion(v: f32) -> f32 {
362    let v = v.clamp(0.0, 1.0);
363    let scale = (v * 1023.0) as usize;
364    if scale < 192 {
365        SRGB_SAMPLES_1[scale] as f32 / 255.0
366    } else {
367        let idx = scale / 4 - 48;
368        SRGB_SAMPLES_2[idx.min(SRGB_SAMPLES_2.len() - 1)] as f32 / 255.0
369    }
370}
371
372/// Convert CIE XYZ to sRGB, returning (r, g, b) each in [0, 1].
373///
374/// Uses upstream PDFium's exact matrix coefficients and sRGB lookup table
375/// (cpdf_colorspace.cpp `XYZ_to_sRGB`).
376fn xyz_to_srgb(x: f32, y: f32, z: f32, _white_point: &[f32; 3]) -> (f32, f32, f32) {
377    let r_lin = 3.2410 * x - 1.5374 * y - 0.4986 * z;
378    let g_lin = -0.9692 * x + 1.8760 * y + 0.0416 * z;
379    let b_lin = 0.0556 * x - 0.2040 * y + 1.0570 * z;
380    (
381        rgb_conversion(r_lin),
382        rgb_conversion(g_lin),
383        rgb_conversion(b_lin),
384    )
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use rpdfium_graphics::Color;
391
392    #[test]
393    fn test_gray_to_rgba() {
394        let color = Color::gray(0.5);
395        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
396        // 0.5 * 255.0 = 127.5 → rounds to 128
397        assert_eq!(rgba.r, 128);
398        assert_eq!(rgba.g, 128);
399        assert_eq!(rgba.b, 128);
400        assert_eq!(rgba.a, 255);
401    }
402
403    #[test]
404    fn test_rgb_to_rgba() {
405        let color = Color::rgb(1.0, 0.0, 0.0);
406        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
407        assert_eq!(rgba.r, 255);
408        assert_eq!(rgba.g, 0);
409        assert_eq!(rgba.b, 0);
410        assert_eq!(rgba.a, 255);
411    }
412
413    #[test]
414    fn test_cmyk_to_rgba() {
415        // Pure cyan via Adobe CMYK table: C=1, M=0, Y=0, K=0 → (0, 174, 239)
416        let color = Color::cmyk(1.0, 0.0, 0.0, 0.0);
417        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
418        assert_eq!(rgba.r, 0);
419        assert_eq!(rgba.g, 174);
420        assert_eq!(rgba.b, 239);
421        assert_eq!(rgba.a, 255);
422    }
423
424    #[test]
425    fn test_cmyk_black() {
426        // K=1 via Adobe CMYK table → (35, 31, 32)
427        let color = Color::cmyk(0.0, 0.0, 0.0, 1.0);
428        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
429        assert_eq!(rgba.r, 35);
430        assert_eq!(rgba.g, 31);
431        assert_eq!(rgba.b, 32);
432    }
433
434    #[test]
435    fn test_alpha_scaling() {
436        let color = Color::gray(1.0);
437        let rgba = RgbaColor::from_pdf_color(&color, 0.5);
438        // 0.5 * 255.0 = 127.5 → rounds to 128
439        assert_eq!(rgba.a, 128);
440    }
441
442    #[test]
443    fn test_clamping_out_of_range() {
444        let color = Color {
445            components: vec![1.5, -0.5, 2.0],
446        };
447        let rgba = RgbaColor::from_pdf_color(&color, 1.5);
448        assert_eq!(rgba.r, 255);
449        assert_eq!(rgba.g, 0);
450        assert_eq!(rgba.b, 255);
451        assert_eq!(rgba.a, 255);
452    }
453
454    #[test]
455    fn test_constants() {
456        assert_eq!(RgbaColor::WHITE, RgbaColor::new(255, 255, 255, 255));
457        assert_eq!(RgbaColor::BLACK, RgbaColor::new(0, 0, 0, 255));
458        assert_eq!(RgbaColor::TRANSPARENT, RgbaColor::new(0, 0, 0, 0));
459    }
460
461    // --- ICC profile tests ---
462
463    #[test]
464    fn test_icc_based_no_profile_falls_back_to_alternate() {
465        let cs = ResolvedColorSpace {
466            family: rpdfium_graphics::ColorSpaceFamily::ICCBased,
467            params: ColorSpaceParams::ICCBased {
468                n_components: 3,
469                alternate: Box::new(ResolvedColorSpace::device_rgb()),
470                icc_profile_data: None,
471            },
472        };
473        let color = Color::rgb(1.0, 0.0, 0.0);
474        let rgba = RgbaColor::from_resolved_color(&color, &cs, 1.0);
475        assert_eq!(rgba.r, 255);
476        assert_eq!(rgba.g, 0);
477        assert_eq!(rgba.b, 0);
478    }
479
480    #[test]
481    fn test_icc_based_garbage_profile_falls_back() {
482        let cs = ResolvedColorSpace {
483            family: rpdfium_graphics::ColorSpaceFamily::ICCBased,
484            params: ColorSpaceParams::ICCBased {
485                n_components: 3,
486                alternate: Box::new(ResolvedColorSpace::device_rgb()),
487                icc_profile_data: Some(vec![0, 1, 2, 3]),
488            },
489        };
490        let color = Color::rgb(0.0, 1.0, 0.0);
491        let rgba = RgbaColor::from_resolved_color(&color, &cs, 1.0);
492        // Falls back to alternate (DeviceRGB)
493        assert_eq!(rgba.r, 0);
494        assert_eq!(rgba.g, 255);
495        assert_eq!(rgba.b, 0);
496    }
497
498    #[test]
499    fn test_icc_transform_unknown_components_returns_none() {
500        let color = Color {
501            components: vec![0.5, 0.5],
502        };
503        // 2 components is not supported
504        let result = icc_transform(&color, &[0, 1, 2, 3], 2);
505        assert!(result.is_none());
506    }
507
508    #[test]
509    fn test_icc_transform_gray_layout() {
510        // Invalid profile, but tests the layout selection code path
511        let color = Color::gray(0.5);
512        let result = icc_transform(&color, &[0, 1, 2, 3], 1);
513        // Invalid profile data → returns None
514        assert!(result.is_none());
515    }
516
517    #[test]
518    fn test_icc_transform_cmyk_layout() {
519        // Invalid profile, but tests the layout selection code path
520        let color = Color::cmyk(0.0, 0.0, 0.0, 0.0);
521        let result = icc_transform(&color, &[0, 1, 2, 3], 4);
522        // Invalid profile data → returns None
523        assert!(result.is_none());
524    }
525
526    // --- Boundary clamping tests ---
527
528    #[test]
529    fn test_from_pdf_color_gray_negative() {
530        let color = Color {
531            components: vec![-0.5],
532        };
533        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
534        assert_eq!(rgba.r, 0);
535        assert_eq!(rgba.g, 0);
536        assert_eq!(rgba.b, 0);
537    }
538
539    #[test]
540    fn test_from_pdf_color_gray_over_one() {
541        let color = Color {
542            components: vec![1.5],
543        };
544        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
545        assert_eq!(rgba.r, 255);
546        assert_eq!(rgba.g, 255);
547        assert_eq!(rgba.b, 255);
548    }
549
550    #[test]
551    fn test_from_pdf_color_rgb_mixed_bounds() {
552        let color = Color {
553            components: vec![-0.1, 0.5, 1.2],
554        };
555        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
556        assert_eq!(rgba.r, 0);
557        assert_eq!(rgba.g, 128);
558        assert_eq!(rgba.b, 255);
559    }
560
561    #[test]
562    fn test_from_pdf_color_rgb_all_negative() {
563        let color = Color {
564            components: vec![-1.0, -2.0, -3.0],
565        };
566        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
567        assert_eq!(rgba.r, 0);
568        assert_eq!(rgba.g, 0);
569        assert_eq!(rgba.b, 0);
570    }
571
572    #[test]
573    fn test_from_pdf_color_rgb_all_over() {
574        let color = Color {
575            components: vec![2.0, 3.0, 4.0],
576        };
577        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
578        assert_eq!(rgba.r, 255);
579        assert_eq!(rgba.g, 255);
580        assert_eq!(rgba.b, 255);
581    }
582
583    #[test]
584    fn test_from_pdf_color_cmyk_negative_values() {
585        // C=-0.5 clamped to 0, M=-0.1 clamped to 0, Y=0.0, K=0.0 → white
586        let color = Color {
587            components: vec![-0.5, -0.1, 0.0, 0.0],
588        };
589        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
590        assert_eq!(rgba.r, 255);
591        assert_eq!(rgba.g, 255);
592        assert_eq!(rgba.b, 255);
593    }
594
595    #[test]
596    fn test_from_pdf_color_cmyk_over_one() {
597        // All 2.0 clamped to 1.0 → (1-1)*(1-1)*255 = 0 → black
598        let color = Color {
599            components: vec![2.0, 2.0, 2.0, 2.0],
600        };
601        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
602        assert_eq!(rgba.r, 0);
603        assert_eq!(rgba.g, 0);
604        assert_eq!(rgba.b, 0);
605    }
606
607    #[test]
608    fn test_from_pdf_color_cmyk_mixed_clamping() {
609        // C=-0.1→0, M=0.5, Y=1.5→1.0, K=0.3 via Adobe CMYK table
610        let color = Color {
611            components: vec![-0.1, 0.5, 1.5, 0.3],
612        };
613        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
614        // Adobe CMYK interpolation gives different values than simple formula
615        assert_eq!(rgba.r, 183);
616        assert_eq!(rgba.g, 109);
617        assert_eq!(rgba.b, 17);
618    }
619
620    #[test]
621    fn test_from_pdf_color_alpha_negative() {
622        let color = Color::gray(0.5);
623        let rgba = RgbaColor::from_pdf_color(&color, -1.0);
624        assert_eq!(rgba.a, 0);
625    }
626
627    #[test]
628    fn test_from_pdf_color_alpha_over_one() {
629        let color = Color::gray(0.5);
630        let rgba = RgbaColor::from_pdf_color(&color, 2.5);
631        assert_eq!(rgba.a, 255);
632    }
633
634    #[test]
635    fn test_from_pdf_color_zero_components() {
636        let color = Color { components: vec![] };
637        let rgba = RgbaColor::from_pdf_color(&color, 0.8);
638        // Fallback: (0, 0, 0, alpha)
639        assert_eq!(rgba.r, 0);
640        assert_eq!(rgba.g, 0);
641        assert_eq!(rgba.b, 0);
642        assert_eq!(rgba.a, 204); // 0.8 * 255 = 204
643    }
644
645    #[test]
646    fn test_from_pdf_color_five_components() {
647        let color = Color {
648            components: vec![0.1, 0.2, 0.3, 0.4, 0.5],
649        };
650        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
651        // Fallback: (0, 0, 0, 255)
652        assert_eq!(rgba.r, 0);
653        assert_eq!(rgba.g, 0);
654        assert_eq!(rgba.b, 0);
655        assert_eq!(rgba.a, 255);
656    }
657
658    #[test]
659    fn test_cmyk_stress_low_values() {
660        // Tiny CMYK values near zero, K=0 via Adobe CMYK table
661        let color = Color::cmyk(0.001, 0.002, 0.003, 0.0);
662        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
663        // Adobe CMYK: near-zero ink → near-white
664        assert_eq!(rgba.r, 254);
665        assert_eq!(rgba.g, 254);
666        assert_eq!(rgba.b, 253);
667    }
668
669    #[test]
670    fn test_cmyk_half_key() {
671        // (0,0,0,0.5) via Adobe CMYK table → gray
672        let color = Color::cmyk(0.0, 0.0, 0.0, 0.5);
673        let rgba = RgbaColor::from_pdf_color(&color, 1.0);
674        // Adobe CMYK: K=0.5 → (147, 149, 152)
675        assert_eq!(rgba.r, 147);
676        assert_eq!(rgba.g, 149);
677        assert_eq!(rgba.b, 152);
678    }
679}