Skip to main content

pdf_interpret/
color.rs

1//! PDF colors and color spaces.
2
3use crate::cache::{Cache, CacheKey};
4use crate::function::Function;
5use log::warn;
6use moxcms::{
7    ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor,
8    TransformF32BitExecutor, TransformOptions, Xyzd,
9};
10use pdf_syntax::object;
11use pdf_syntax::object::Array;
12use pdf_syntax::object::Dict;
13use pdf_syntax::object::Name;
14use pdf_syntax::object::Object;
15use pdf_syntax::object::Stream;
16use pdf_syntax::object::dict::keys::*;
17use smallvec::{SmallVec, ToSmallVec, smallvec};
18use std::fmt::{Debug, Formatter};
19use std::ops::Deref;
20use std::sync::{Arc, OnceLock};
21
22/// Default DeviceCMYK → sRGB profile (CGATS001Compat-v2-micro, same family as MuPDF's built-in).
23/// Loaded once on first use; falls back to the PDF spec formula if loading fails.
24static DEFAULT_CMYK_PROFILE: OnceLock<Option<ICCProfile>> = OnceLock::new();
25
26fn default_cmyk_profile() -> Option<&'static ICCProfile> {
27    DEFAULT_CMYK_PROFILE
28        .get_or_init(|| ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4))
29        .as_ref()
30}
31
32/// A storage for the components of colors.
33pub type ColorComponents = SmallVec<[f32; 4]>;
34
35/// An RGB color with an alpha channel.
36#[derive(Debug, Copy, Clone)]
37pub struct AlphaColor {
38    components: [f32; 4],
39}
40
41impl AlphaColor {
42    /// A black color.
43    pub const BLACK: Self = Self::new([0., 0., 0., 1.]);
44
45    /// A transparent color.
46    pub const TRANSPARENT: Self = Self::new([0., 0., 0., 0.]);
47
48    /// A white color.
49    pub const WHITE: Self = Self::new([1., 1., 1., 1.]);
50
51    /// Create a new color from the given components.
52    pub const fn new(components: [f32; 4]) -> Self {
53        Self { components }
54    }
55
56    /// Create a new color from RGB8 values.
57    pub const fn from_rgb8(r: u8, g: u8, b: u8) -> Self {
58        let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b), 1.];
59        Self::new(components)
60    }
61
62    /// Return the color as premulitplied RGBF32.
63    pub fn premultiplied(&self) -> [f32; 4] {
64        [
65            self.components[0] * self.components[3],
66            self.components[1] * self.components[3],
67            self.components[2] * self.components[3],
68            self.components[3],
69        ]
70    }
71
72    /// Create a new color from RGBA8 values.
73    pub const fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
74        let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b), u8_to_f32(a)];
75        Self::new(components)
76    }
77
78    /// Return the color as RGBA8.
79    pub fn to_rgba8(&self) -> [u8; 4] {
80        [
81            (self.components[0] * 255.0 + 0.5) as u8,
82            (self.components[1] * 255.0 + 0.5) as u8,
83            (self.components[2] * 255.0 + 0.5) as u8,
84            (self.components[3] * 255.0 + 0.5) as u8,
85        ]
86    }
87
88    /// Return the components of the color as RGBF32.
89    pub fn components(&self) -> [f32; 4] {
90        self.components
91    }
92}
93
94const fn u8_to_f32(x: u8) -> f32 {
95    x as f32 * (1.0 / 255.0)
96}
97
98#[derive(Debug, Clone)]
99pub(crate) enum ColorSpaceType {
100    DeviceCmyk,
101    DeviceGray,
102    DeviceRgb,
103    Pattern(ColorSpace),
104    Indexed(Indexed),
105    ICCBased(ICCProfile),
106    CalGray(CalGray),
107    CalRgb(CalRgb),
108    Lab(Lab),
109    Separation(Separation),
110    DeviceN(DeviceN),
111}
112
113impl ColorSpaceType {
114    fn new(object: Object<'_>, cache: &Cache) -> Option<Self> {
115        Self::new_inner(object, cache)
116    }
117
118    fn new_inner(object: Object<'_>, cache: &Cache) -> Option<Self> {
119        if let Some(name) = object.clone().into_name() {
120            return Self::new_from_name(name.clone());
121        } else if let Some(color_array) = object.clone().into_array() {
122            let mut iter = color_array.clone().flex_iter();
123            let name = iter.next::<Name>()?;
124
125            match name.deref() {
126                ICC_BASED => {
127                    let icc_stream = iter.next::<Stream<'_>>()?;
128                    let dict = icc_stream.dict();
129                    // `N` is the declared component count. PDF 2.0 §8.6.5.5 says
130                    // it shall be 1, 3 or 4. If the entry is missing, negative,
131                    // or otherwise unusable, the spec requires falling through
132                    // to /Alternate rather than giving up on the color space.
133                    let num_components = dict.get::<usize>(N);
134
135                    return cache.get_or_insert_with(icc_stream.cache_key(), || {
136                        // Only try to parse the embedded ICC profile when N is
137                        // valid — without it we can't know how many channels
138                        // to decode.
139                        let from_icc = num_components.and_then(|n| {
140                            icc_stream.decoded().ok().as_ref().and_then(|decoded| {
141                                ICCProfile::new(decoded, n).map(|icc| {
142                                    // TODO: For SVG and PNG we can assume that the output color space is
143                                    // sRGB. If we ever implement PDF-to-PDF, we probably want to
144                                    // let the user pass the native color type and don't make this optimization
145                                    // if it's not sRGB.
146                                    if icc.is_srgb() {
147                                        Self::DeviceRgb
148                                    } else {
149                                        Self::ICCBased(icc)
150                                    }
151                                })
152                            })
153                        });
154
155                        from_icc
156                            .or_else(|| {
157                                dict.get::<Object<'_>>(ALTERNATE)
158                                    .and_then(|o| Self::new(o, cache))
159                            })
160                            .or_else(|| match num_components {
161                                Some(1) => Some(Self::DeviceGray),
162                                Some(3) => Some(Self::DeviceRgb),
163                                Some(4) => Some(Self::DeviceCmyk),
164                                _ => None,
165                            })
166                    });
167                }
168                CALCMYK => return Some(Self::DeviceCmyk),
169                CALGRAY => {
170                    let cal_dict = iter.next::<Dict<'_>>()?;
171                    return Some(Self::CalGray(CalGray::new(&cal_dict)?));
172                }
173                CALRGB => {
174                    let cal_dict = iter.next::<Dict<'_>>()?;
175                    return Some(Self::CalRgb(CalRgb::new(&cal_dict)?));
176                }
177                DEVICE_RGB | RGB => return Some(Self::DeviceRgb),
178                DEVICE_GRAY | G => return Some(Self::DeviceGray),
179                DEVICE_CMYK | CMYK => return Some(Self::DeviceCmyk),
180                LAB => {
181                    let lab_dict = iter.next::<Dict<'_>>()?;
182                    return Some(Self::Lab(Lab::new(&lab_dict)?));
183                }
184                INDEXED | I => {
185                    return Some(Self::Indexed(Indexed::new(&color_array, cache)?));
186                }
187                SEPARATION => {
188                    return Some(Self::Separation(Separation::new(&color_array, cache)?));
189                }
190                DEVICE_N => {
191                    return Some(Self::DeviceN(DeviceN::new(&color_array, cache)?));
192                }
193                PATTERN => {
194                    // Base colorspace is the next element: [/Pattern /DeviceCMYK] or
195                    // [/Pattern [/ICCBased ...]] etc. Do NOT skip an extra element here.
196                    let cs = iter
197                        .next::<Object<'_>>()
198                        .and_then(|o| ColorSpace::new(o, cache))
199                        .unwrap_or(ColorSpace::device_rgb());
200                    return Some(Self::Pattern(cs));
201                }
202                _ => {
203                    warn!("unsupported color space: {}", name.as_str());
204                    return None;
205                }
206            }
207        }
208
209        None
210    }
211
212    fn new_from_name(name: Name) -> Option<Self> {
213        match name.deref() {
214            DEVICE_RGB | RGB => Some(Self::DeviceRgb),
215            DEVICE_GRAY | G => Some(Self::DeviceGray),
216            DEVICE_CMYK | CMYK => Some(Self::DeviceCmyk),
217            CALCMYK => Some(Self::DeviceCmyk),
218            PATTERN => Some(Self::Pattern(ColorSpace::device_rgb())),
219            _ => None,
220        }
221    }
222}
223
224/// A PDF color space.
225#[derive(Debug, Clone)]
226pub struct ColorSpace(Arc<ColorSpaceType>);
227
228impl ColorSpace {
229    /// Create a new color space from the given object.
230    pub(crate) fn new(object: Object<'_>, cache: &Cache) -> Option<Self> {
231        Some(Self(Arc::new(ColorSpaceType::new(object, cache)?)))
232    }
233
234    /// Create a new color space from the name.
235    pub(crate) fn new_from_name(name: Name) -> Option<Self> {
236        ColorSpaceType::new_from_name(name).map(|c| Self(Arc::new(c)))
237    }
238
239    /// Return the device gray color space.
240    pub(crate) fn device_gray() -> Self {
241        Self(Arc::new(ColorSpaceType::DeviceGray))
242    }
243
244    /// Return the device RGB color space.
245    pub(crate) fn device_rgb() -> Self {
246        Self(Arc::new(ColorSpaceType::DeviceRgb))
247    }
248
249    /// Return the device CMYK color space.
250    pub(crate) fn device_cmyk() -> Self {
251        Self(Arc::new(ColorSpaceType::DeviceCmyk))
252    }
253
254    /// Return `true` if the current color space is DeviceRGB.
255    pub fn is_device_rgb(&self) -> bool {
256        matches!(*self.0, ColorSpaceType::DeviceRgb)
257    }
258
259    /// Return `true` if the current color space is DeviceCMYK.
260    pub fn is_device_cmyk(&self) -> bool {
261        matches!(*self.0, ColorSpaceType::DeviceCmyk)
262    }
263
264    /// Return the pattern color space.
265    pub(crate) fn pattern() -> Self {
266        Self(Arc::new(ColorSpaceType::Pattern(Self::device_gray())))
267    }
268
269    pub(crate) fn pattern_cs(&self) -> Option<Self> {
270        match self.0.as_ref() {
271            ColorSpaceType::Pattern(cs) => Some(cs.clone()),
272            _ => None,
273        }
274    }
275
276    /// Return `true` if the current color space is the pattern color space.
277    pub(crate) fn is_pattern(&self) -> bool {
278        matches!(self.0.as_ref(), ColorSpaceType::Pattern(_))
279    }
280
281    /// Return `true` if the current color space is an indexed color space.
282    pub(crate) fn is_indexed(&self) -> bool {
283        matches!(self.0.as_ref(), ColorSpaceType::Indexed(_))
284    }
285
286    /// Get the default decode array for the color space.
287    pub(crate) fn default_decode_arr(&self, n: f32) -> SmallVec<[(f32, f32); 4]> {
288        match self.0.as_ref() {
289            ColorSpaceType::DeviceCmyk => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
290            ColorSpaceType::DeviceGray => smallvec![(0.0, 1.0)],
291            ColorSpaceType::DeviceRgb => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
292            ColorSpaceType::ICCBased(i) => smallvec![(0.0, 1.0); i.0.number_components],
293            ColorSpaceType::CalGray(_) => smallvec![(0.0, 1.0)],
294            ColorSpaceType::CalRgb(_) => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
295            ColorSpaceType::Lab(l) => smallvec![
296                (0.0, 100.0),
297                (l.range[0], l.range[1]),
298                (l.range[2], l.range[3]),
299            ],
300            ColorSpaceType::Indexed(_) => smallvec![(0.0, 2.0_f32.powf(n) - 1.0)],
301            ColorSpaceType::Separation(_) => smallvec![(0.0, 1.0)],
302            ColorSpaceType::DeviceN(d) => smallvec![(0.0, 1.0); d.num_components as usize],
303            // Not a valid image color space.
304            ColorSpaceType::Pattern(_) => smallvec![(0.0, 1.0)],
305        }
306    }
307
308    /// Get the initial color of the color space.
309    pub(crate) fn initial_color(&self) -> ColorComponents {
310        match self.0.as_ref() {
311            ColorSpaceType::DeviceCmyk => smallvec![0.0, 0.0, 0.0, 1.0],
312            ColorSpaceType::DeviceGray => smallvec![0.0],
313            ColorSpaceType::DeviceRgb => smallvec![0.0, 0.0, 0.0],
314            ColorSpaceType::ICCBased(icc) => match icc.0.number_components {
315                1 => smallvec![0.0],
316                3 => smallvec![0.0, 0.0, 0.0],
317                4 => smallvec![0.0, 0.0, 0.0, 1.0],
318                _ => unreachable!(),
319            },
320            ColorSpaceType::CalGray(_) => smallvec![0.0],
321            ColorSpaceType::CalRgb(_) => smallvec![0.0, 0.0, 0.0],
322            ColorSpaceType::Lab(_) => smallvec![0.0, 0.0, 0.0],
323            ColorSpaceType::Indexed(_) => smallvec![0.0],
324            ColorSpaceType::Separation(_) => smallvec![1.0],
325            ColorSpaceType::Pattern(c) => c.initial_color(),
326            ColorSpaceType::DeviceN(d) => smallvec![1.0; d.num_components as usize],
327        }
328    }
329
330    /// Get the number of components of the color space.
331    pub(crate) fn num_components(&self) -> u8 {
332        match self.0.as_ref() {
333            ColorSpaceType::DeviceCmyk => 4,
334            ColorSpaceType::DeviceGray => 1,
335            ColorSpaceType::DeviceRgb => 3,
336            ColorSpaceType::ICCBased(icc) => icc.0.number_components as u8,
337            ColorSpaceType::CalGray(_) => 1,
338            ColorSpaceType::CalRgb(_) => 3,
339            ColorSpaceType::Lab(_) => 3,
340            ColorSpaceType::Indexed(_) => 1,
341            ColorSpaceType::Separation(_) => 1,
342            ColorSpaceType::Pattern(p) => p.num_components(),
343            ColorSpaceType::DeviceN(d) => d.num_components,
344        }
345    }
346
347    /// Turn the given component values and opacity into an RGBA color.
348    pub fn to_rgba(&self, c: &[f32], opacity: f32, manual_scale: bool) -> AlphaColor {
349        self.to_alpha_color(c, opacity, manual_scale)
350            .unwrap_or(AlphaColor::BLACK)
351    }
352}
353
354impl ToRgb for ColorSpace {
355    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
356        match self.0.as_ref() {
357            ColorSpaceType::DeviceCmyk => {
358                // Use the CGATS001Compat ICC profile (same family as MuPDF's built-in DeviceCMYK
359                // profile) to match MuPDF rendering.  The PDF spec §10.3.5 algebraic formula
360                // R=1−min(1,C+K) hard-clamps to black for rich-black CMYK values (e.g.
361                // C=0.72 K=0.66 → 0), while the ICC gives the perceptually correct dark
362                // charcoal (~RGB 41,42,43) that MuPDF renders.
363                if let Some(profile) = default_cmyk_profile() {
364                    return profile.convert_f32(input, output, manual_scale);
365                }
366                // Fallback (profile load failed): PDF spec formula.
367                for (input, output) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
368                    let (c, m, y, k) = (input[0], input[1], input[2], input[3]);
369                    output[0] = f32_to_u8(1.0 - (c + k).min(1.0));
370                    output[1] = f32_to_u8(1.0 - (m + k).min(1.0));
371                    output[2] = f32_to_u8(1.0 - (y + k).min(1.0));
372                }
373                Some(())
374            }
375            ColorSpaceType::DeviceGray => {
376                let converted = input.iter().copied().map(f32_to_u8).collect::<Vec<_>>();
377
378                for (input, output) in converted.iter().zip(output.chunks_exact_mut(3)) {
379                    output.copy_from_slice(&[*input, *input, *input]);
380                }
381
382                Some(())
383            }
384            ColorSpaceType::DeviceRgb => {
385                for (input, output) in input.iter().copied().zip(output) {
386                    *output = f32_to_u8(input);
387                }
388
389                Some(())
390            }
391            ColorSpaceType::Pattern(i) => i.convert_f32(input, output, manual_scale),
392            ColorSpaceType::Indexed(i) => i.convert_f32(input, output, manual_scale),
393            ColorSpaceType::ICCBased(i) => i.convert_f32(input, output, manual_scale),
394            ColorSpaceType::CalGray(i) => i.convert_f32(input, output, manual_scale),
395            ColorSpaceType::CalRgb(i) => i.convert_f32(input, output, manual_scale),
396            ColorSpaceType::Lab(i) => i.convert_f32(input, output, manual_scale),
397            ColorSpaceType::Separation(i) => i.convert_f32(input, output, manual_scale),
398            ColorSpaceType::DeviceN(i) => i.convert_f32(input, output, manual_scale),
399        }
400    }
401
402    fn supports_u8(&self) -> bool {
403        match self.0.as_ref() {
404            ColorSpaceType::DeviceCmyk => true,
405            ColorSpaceType::DeviceGray => true,
406            ColorSpaceType::DeviceRgb => true,
407            ColorSpaceType::Pattern(i) => i.supports_u8(),
408            ColorSpaceType::Indexed(i) => i.supports_u8(),
409            ColorSpaceType::ICCBased(i) => i.supports_u8(),
410            ColorSpaceType::CalGray(i) => i.supports_u8(),
411            ColorSpaceType::CalRgb(i) => i.supports_u8(),
412            ColorSpaceType::Lab(i) => i.supports_u8(),
413            ColorSpaceType::Separation(i) => i.supports_u8(),
414            ColorSpaceType::DeviceN(i) => i.supports_u8(),
415        }
416    }
417
418    fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> {
419        match self.0.as_ref() {
420            ColorSpaceType::DeviceCmyk => {
421                // Use ICC profile (see convert_f32 comment above).
422                if let Some(profile) = default_cmyk_profile() {
423                    return profile.convert_u8(input, output);
424                }
425                // Fallback: PDF spec §10.3.5 formula (u8 domain).
426                for (input, output) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
427                    let (c, m, y, k) = (
428                        input[0] as u16,
429                        input[1] as u16,
430                        input[2] as u16,
431                        input[3] as u16,
432                    );
433                    output[0] = (255u16.saturating_sub(c + k)) as u8;
434                    output[1] = (255u16.saturating_sub(m + k)) as u8;
435                    output[2] = (255u16.saturating_sub(y + k)) as u8;
436                }
437                Some(())
438            }
439            ColorSpaceType::DeviceGray => {
440                for (input, output) in input.iter().zip(output.chunks_exact_mut(3)) {
441                    output.copy_from_slice(&[*input, *input, *input]);
442                }
443
444                Some(())
445            }
446            ColorSpaceType::DeviceRgb => {
447                output.copy_from_slice(input);
448
449                Some(())
450            }
451            ColorSpaceType::Pattern(i) => i.convert_u8(input, output),
452            ColorSpaceType::Indexed(i) => i.convert_u8(input, output),
453            ColorSpaceType::ICCBased(i) => i.convert_u8(input, output),
454            ColorSpaceType::CalGray(i) => i.convert_u8(input, output),
455            ColorSpaceType::CalRgb(i) => i.convert_u8(input, output),
456            ColorSpaceType::Lab(i) => i.convert_u8(input, output),
457            ColorSpaceType::Separation(i) => i.convert_u8(input, output),
458            ColorSpaceType::DeviceN(i) => i.convert_u8(input, output),
459        }
460    }
461
462    fn is_none(&self) -> bool {
463        match self.0.as_ref() {
464            ColorSpaceType::Separation(s) => s.is_none(),
465            ColorSpaceType::DeviceN(d) => d.is_none(),
466            _ => false,
467        }
468    }
469}
470
471#[derive(Debug, Clone)]
472pub(crate) struct CalGray {
473    white_point: [f32; 3],
474    black_point: [f32; 3],
475    gamma: f32,
476}
477
478// See <https://github.com/mozilla/pdf.js/blob/06f44916c8936b92f464d337fe3a0a6b2b78d5b4/src/core/colorspace.js#L752>
479impl CalGray {
480    fn new(dict: &Dict<'_>) -> Option<Self> {
481        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
482        let black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
483        let gamma = dict.get::<f32>(GAMMA).unwrap_or(1.0);
484
485        Some(Self {
486            white_point,
487            black_point,
488            gamma,
489        })
490    }
491}
492
493impl ToRgb for CalGray {
494    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
495        for (input, output) in input.iter().copied().zip(output.chunks_exact_mut(3)) {
496            let g = self.gamma;
497            let (_xw, yw, _zw) = {
498                let wp = self.white_point;
499                (wp[0], wp[1], wp[2])
500            };
501            let (_xb, _yb, _zb) = {
502                let bp = self.black_point;
503                (bp[0], bp[1], bp[2])
504            };
505
506            let a = input;
507            let ag = a.powf(g);
508            let l = yw * ag;
509            let val = (0.0_f32.max(295.8 * l.powf(0.333_333_34) - 40.8) + 0.5) as u8;
510
511            output.copy_from_slice(&[val, val, val]);
512        }
513
514        Some(())
515    }
516}
517
518#[derive(Debug, Clone)]
519pub(crate) struct CalRgb {
520    white_point: [f32; 3],
521    black_point: [f32; 3],
522    matrix: [f32; 9],
523    gamma: [f32; 3],
524}
525
526// See <https://github.com/mozilla/pdf.js/blob/06f44916c8936b92f464d337fe3a0a6b2b78d5b4/src/core/colorspace.js#L846>
527// Completely copied from there without really understanding the logic, but we get the same results as Firefox
528// which should be good enough (and by viewing the `calrgb.pdf` test file in different viewers you will
529// see that in many cases each viewer does whatever it wants, even Acrobat), so this is good enough for us.
530impl CalRgb {
531    fn new(dict: &Dict<'_>) -> Option<Self> {
532        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
533        let black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
534        let matrix = dict
535            .get::<[f32; 9]>(MATRIX)
536            .unwrap_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
537        let gamma = dict.get::<[f32; 3]>(GAMMA).unwrap_or([1.0, 1.0, 1.0]);
538
539        Some(Self {
540            white_point,
541            black_point,
542            matrix,
543            gamma,
544        })
545    }
546
547    const BRADFORD_SCALE_MATRIX: [f32; 9] = [
548        0.8951, 0.2664, -0.1614, -0.7502, 1.7135, 0.0367, 0.0389, -0.0685, 1.0296,
549    ];
550
551    const BRADFORD_SCALE_INVERSE_MATRIX: [f32; 9] = [
552        0.9869929, -0.1470543, 0.1599627, 0.4323053, 0.5183603, 0.0492912, -0.0085287, 0.0400428,
553        0.9684867,
554    ];
555
556    const SRGB_D65_XYZ_TO_RGB_MATRIX: [f32; 9] = [
557        3.2404542, -1.5371385, -0.4985314, -0.969_266, 1.8760108, 0.0415560, 0.0556434, -0.2040259,
558        1.0572252,
559    ];
560
561    const FLAT_WHITEPOINT: [f32; 3] = [1.0, 1.0, 1.0];
562    const D65_WHITEPOINT: [f32; 3] = [0.95047, 1.0, 1.08883];
563
564    fn decode_l_constant() -> f32 {
565        ((8.0_f32 + 16.0) / 116.0).powi(3) / 8.0
566    }
567
568    fn srgb_transfer_function(color: f32) -> f32 {
569        if color <= 0.0031308 {
570            (12.92 * color).clamp(0.0, 1.0)
571        } else if color >= 0.99554525 {
572            1.0
573        } else {
574            ((1.0 + 0.055) * color.powf(1.0 / 2.4) - 0.055).clamp(0.0, 1.0)
575        }
576    }
577
578    fn matrix_product(a: &[f32; 9], b: &[f32; 3]) -> [f32; 3] {
579        [
580            a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
581            a[3] * b[0] + a[4] * b[1] + a[5] * b[2],
582            a[6] * b[0] + a[7] * b[1] + a[8] * b[2],
583        ]
584    }
585
586    fn to_flat(source_white_point: &[f32; 3], lms: &[f32; 3]) -> [f32; 3] {
587        [
588            lms[0] / source_white_point[0],
589            lms[1] / source_white_point[1],
590            lms[2] / source_white_point[2],
591        ]
592    }
593
594    fn to_d65(source_white_point: &[f32; 3], lms: &[f32; 3]) -> [f32; 3] {
595        [
596            lms[0] * Self::D65_WHITEPOINT[0] / source_white_point[0],
597            lms[1] * Self::D65_WHITEPOINT[1] / source_white_point[1],
598            lms[2] * Self::D65_WHITEPOINT[2] / source_white_point[2],
599        ]
600    }
601
602    fn decode_l(l: f32) -> f32 {
603        if l < 0.0 {
604            -Self::decode_l(-l)
605        } else if l > 8.0 {
606            ((l + 16.0) / 116.0).powi(3)
607        } else {
608            l * Self::decode_l_constant()
609        }
610    }
611
612    fn compensate_black_point(source_bp: &[f32; 3], xyz_flat: &[f32; 3]) -> [f32; 3] {
613        if source_bp == &[0.0, 0.0, 0.0] {
614            return *xyz_flat;
615        }
616
617        let zero_decode_l = Self::decode_l(0.0);
618
619        let mut out = [0.0; 3];
620        for i in 0..3 {
621            let src = Self::decode_l(source_bp[i]);
622            let scale = (1.0 - zero_decode_l) / (1.0 - src);
623            let offset = 1.0 - scale;
624            out[i] = xyz_flat[i] * scale + offset;
625        }
626
627        out
628    }
629
630    fn normalize_white_point_to_flat(
631        &self,
632        source_white_point: &[f32; 3],
633        xyz: &[f32; 3],
634    ) -> [f32; 3] {
635        if source_white_point[0] == 1.0 && source_white_point[2] == 1.0 {
636            return *xyz;
637        }
638        let lms = Self::matrix_product(&Self::BRADFORD_SCALE_MATRIX, xyz);
639        let lms_flat = Self::to_flat(source_white_point, &lms);
640        Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_flat)
641    }
642
643    fn normalize_white_point_to_d65(
644        &self,
645        source_white_point: &[f32; 3],
646        xyz: &[f32; 3],
647    ) -> [f32; 3] {
648        let lms = Self::matrix_product(&Self::BRADFORD_SCALE_MATRIX, xyz);
649        let lms_d65 = Self::to_d65(source_white_point, &lms);
650        Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_d65)
651    }
652}
653
654impl ToRgb for CalRgb {
655    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
656        for (input, output) in input.chunks_exact(3).zip(output.chunks_exact_mut(3)) {
657            let input = [
658                input[0].clamp(0.0, 1.0),
659                input[1].clamp(0.0, 1.0),
660                input[2].clamp(0.0, 1.0),
661            ];
662
663            let [r, g, b] = input;
664            let [gr, gg, gb] = self.gamma;
665            let [agr, bgg, cgb] = [
666                if r == 1.0 { 1.0 } else { r.powf(gr) },
667                if g == 1.0 { 1.0 } else { g.powf(gg) },
668                if b == 1.0 { 1.0 } else { b.powf(gb) },
669            ];
670
671            let m = &self.matrix;
672            let x = m[0] * agr + m[3] * bgg + m[6] * cgb;
673            let y = m[1] * agr + m[4] * bgg + m[7] * cgb;
674            let z = m[2] * agr + m[5] * bgg + m[8] * cgb;
675            let xyz = [x, y, z];
676
677            let xyz_flat = self.normalize_white_point_to_flat(&self.white_point, &xyz);
678            let xyz_black = Self::compensate_black_point(&self.black_point, &xyz_flat);
679            let xyz_d65 = self.normalize_white_point_to_d65(&Self::FLAT_WHITEPOINT, &xyz_black);
680            let srgb_xyz = Self::matrix_product(&Self::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65);
681
682            output.copy_from_slice(&[
683                (Self::srgb_transfer_function(srgb_xyz[0]) * 255.0 + 0.5) as u8,
684                (Self::srgb_transfer_function(srgb_xyz[1]) * 255.0 + 0.5) as u8,
685                (Self::srgb_transfer_function(srgb_xyz[2]) * 255.0 + 0.5) as u8,
686            ]);
687        }
688
689        Some(())
690    }
691}
692
693#[derive(Debug, Clone)]
694pub(crate) struct Lab {
695    range: [f32; 4],
696    profile: ICCProfile,
697}
698
699impl Lab {
700    fn new(dict: &Dict<'_>) -> Option<Self> {
701        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
702        // Not sure how this should be used.
703        let _black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
704        let range = dict
705            .get::<[f32; 4]>(RANGE)
706            .unwrap_or([-100.0, 100.0, -100.0, 100.0]);
707
708        let mut profile = ColorProfile::new_from_slice(include_bytes!("../assets/LAB.icc")).ok()?;
709        profile.white_point = Xyzd::new(
710            white_point[0] as f64,
711            white_point[1] as f64,
712            white_point[2] as f64,
713        );
714
715        let profile = ICCProfile::new_from_src_profile(
716            profile, false,
717            // This flag is only used to scale the values to [0.0, 1.0], but
718            // we already take care of this in the `convert_f32` method.
719            // Therefore, leave this as false, even though this is a LAB profile.
720            false, 3,
721        )?;
722
723        Some(Self { range, profile })
724    }
725}
726
727impl ToRgb for Lab {
728    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
729        if !manual_scale {
730            // moxcms expects values between 0.0 and 1.0, so we need to undo
731            // the scaling.
732
733            let input = input
734                .chunks_exact(3)
735                .flat_map(|i| {
736                    let l = i[0] / 100.0;
737                    let a = (i[1] + 128.0) / 255.0;
738                    let b = (i[2] + 128.0) / 255.0;
739
740                    [l, a, b]
741                })
742                .collect::<Vec<_>>();
743
744            self.profile.convert_f32(&input, output, manual_scale)
745        } else {
746            self.profile.convert_f32(input, output, manual_scale)
747        }
748    }
749}
750
751#[derive(Debug, Clone)]
752pub(crate) struct Indexed {
753    values: Vec<Vec<f32>>,
754    hival: u8,
755    base: Box<ColorSpace>,
756}
757
758impl Indexed {
759    fn new(array: &Array<'_>, cache: &Cache) -> Option<Self> {
760        let mut iter = array.flex_iter();
761        // Skip name
762        let _ = iter.next::<Name>()?;
763        let base_color_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
764        let hival = iter.next::<u8>()?;
765
766        let values = {
767            let data = iter
768                .next::<Stream<'_>>()
769                .and_then(|s| s.decoded().ok())
770                .or_else(|| iter.next::<object::String>().map(|s| s.to_vec()))?;
771
772            let num_components = base_color_space.num_components();
773
774            let mut byte_iter = data.iter().copied();
775
776            let mut vals = vec![];
777            for _ in 0..=hival {
778                let mut temp = vec![];
779
780                for _ in 0..num_components {
781                    temp.push(byte_iter.next()? as f32 / 255.0);
782                }
783
784                vals.push(temp);
785            }
786
787            vals
788        };
789
790        Some(Self {
791            values,
792            hival,
793            base: Box::new(base_color_space),
794        })
795    }
796}
797
798impl ToRgb for Indexed {
799    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
800        let mut indexed = vec![0.0; input.len() * self.base.num_components() as usize];
801
802        for (input, output) in input
803            .iter()
804            .copied()
805            .zip(indexed.chunks_exact_mut(self.base.num_components() as usize))
806        {
807            let idx = (input.clamp(0.0, self.hival as f32) + 0.5) as usize;
808            output.copy_from_slice(&self.values[idx]);
809        }
810
811        self.base.convert_f32(&indexed, output, true)
812    }
813}
814
815#[derive(Debug, Clone)]
816pub(crate) struct Separation {
817    alternate_space: ColorSpace,
818    tint_transform: Function,
819    is_none_separation: bool,
820}
821
822impl Separation {
823    fn new(array: &Array<'_>, cache: &Cache) -> Option<Self> {
824        let mut iter = array.flex_iter();
825        // Skip `/Separation`
826        let _ = iter.next::<Name>()?;
827        let name = iter.next::<Name>()?;
828        let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
829        let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;
830        // PDF spec §8.6.6.4: the special colourant name "None" means no ink —
831        // painting in this colour space has no effect on the page.
832        // Only the literal string "None" triggers this; named inks such as
833        // "PANTONE 123 CVC" or "All" are regular colourants and must NOT be
834        // suppressed here.  ("All" is left to the tint transform; other viewers
835        // treat it as a pass-through as well.)
836        let is_none_separation = name.as_str() == "None";
837
838        Some(Self {
839            alternate_space,
840            tint_transform,
841            is_none_separation,
842        })
843    }
844}
845
846impl ToRgb for Separation {
847    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
848        let evaluated = input
849            .iter()
850            .flat_map(|n| {
851                self.tint_transform
852                    .eval(smallvec![*n])
853                    .unwrap_or(self.alternate_space.initial_color())
854            })
855            .collect::<Vec<_>>();
856        self.alternate_space.convert_f32(&evaluated, output, false)
857    }
858
859    fn is_none(&self) -> bool {
860        self.is_none_separation
861    }
862}
863
864#[derive(Debug, Clone)]
865pub(crate) struct DeviceN {
866    alternate_space: ColorSpace,
867    num_components: u8,
868    tint_transform: Function,
869    is_none: bool,
870}
871
872impl DeviceN {
873    fn new(array: &Array<'_>, cache: &Cache) -> Option<Self> {
874        let mut iter = array.flex_iter();
875        // Skip `/DeviceN`
876        let _ = iter.next::<Name>()?;
877        let names = iter.next::<Array<'_>>()?.iter::<Name>().collect::<Vec<_>>();
878        let num_components = u8::try_from(names.len()).ok()?;
879        // PDF spec §8.6.6.5: suppress paint only when every component is named
880        // "None".  A DeviceN that mixes real inks with "None" (e.g. a spot
881        // colour channel alongside a filler "None" channel) must still paint,
882        // because the tint transform maps all components to the alternate space
883        // simultaneously.
884        let all_none = names.iter().all(|n| n.as_str() == "None");
885        let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
886        let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;
887
888        if num_components == 0 {
889            return None;
890        }
891
892        Some(Self {
893            alternate_space,
894            num_components,
895            tint_transform,
896            is_none: all_none,
897        })
898    }
899}
900
901impl ToRgb for DeviceN {
902    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
903        let evaluated = input
904            .chunks_exact(self.num_components as usize)
905            .flat_map(|n| {
906                self.tint_transform
907                    .eval(n.to_smallvec())
908                    .unwrap_or(self.alternate_space.initial_color())
909            })
910            .collect::<Vec<_>>();
911        self.alternate_space.convert_f32(&evaluated, output, false)
912    }
913
914    fn is_none(&self) -> bool {
915        self.is_none
916    }
917}
918
919struct ICCColorRepr {
920    transform_u8: Box<Transform8BitExecutor>,
921    transform_f32: Box<TransformF32BitExecutor>,
922    number_components: usize,
923    is_srgb: bool,
924    is_lab: bool,
925}
926
927#[derive(Clone)]
928pub(crate) struct ICCProfile(Arc<ICCColorRepr>);
929
930impl Debug for ICCProfile {
931    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
932        write!(f, "ICCColor {{..}}")
933    }
934}
935
936impl ICCProfile {
937    fn new(profile: &[u8], number_components: usize) -> Option<Self> {
938        let src_profile = ColorProfile::new_from_slice(profile).ok()?;
939
940        // Determine the component count declared by the embedded ICC profile.
941        // Producers occasionally emit /N=3 together with a CMYK profile (or
942        // vice versa), which would otherwise cause moxcms to interpret the
943        // pixel bytes through a wrong layout and silently return garbage
944        // colours. Trust the profile's own color-space marker and fall back
945        // gracefully when the /N count disagrees. Cherry-picked surgically
946        // from reverted #920 overhaul (commit 23b7007ba).
947        let profile_components = match src_profile.color_space {
948            DataColorSpace::Gray => 1,
949            DataColorSpace::Rgb
950            | DataColorSpace::Lab
951            | DataColorSpace::Luv
952            | DataColorSpace::Xyz
953            | DataColorSpace::YCbr
954            | DataColorSpace::Yxy
955            | DataColorSpace::Hsv
956            | DataColorSpace::Hls
957            | DataColorSpace::Cmy
958            | DataColorSpace::Color3 => 3,
959            DataColorSpace::Cmyk | DataColorSpace::Color4 => 4,
960            _ => {
961                warn!(
962                    "unsupported ICC profile color space {:?}",
963                    src_profile.color_space
964                );
965                return None;
966            }
967        };
968
969        if number_components != profile_components {
970            warn!(
971                "ICCBased /N={} does not match embedded ICC profile component count {}; using profile",
972                number_components, profile_components
973            );
974        }
975
976        const SRGB_MARKER: &[u8] = b"sRGB";
977
978        let is_srgb = profile
979            .get(52..56)
980            .map(|device_model| device_model == SRGB_MARKER)
981            .unwrap_or(false);
982        let is_lab = src_profile.color_space == DataColorSpace::Lab;
983
984        Self::new_from_src_profile(src_profile, is_srgb, is_lab, profile_components)
985    }
986
987    fn new_from_src_profile(
988        src_profile: ColorProfile,
989        is_srgb: bool,
990        is_lab: bool,
991        number_components: usize,
992    ) -> Option<Self> {
993        let dest_profile = ColorProfile::new_srgb();
994
995        // PDF spec §8.6.5.5 (GL-QA41): ICCBased colorspaces with N=4 components
996        // are CMYK profiles. moxcms uses Layout::Rgba as its 4-channel layout
997        // (documented: "Cmyk8 uses the same layout as Rgba8"), so Layout::Rgba
998        // is the correct choice for 4-component (CMYK) ICC source profiles.
999        // The full ICC transform is applied — no naive CMYK formula fallback —
1000        // which correctly handles embedded ICC CMYK profiles that override the
1001        // default DeviceCMYK-to-sRGB conversion.
1002        let src_layout = match number_components {
1003            1 => Layout::Gray,
1004            3 => Layout::Rgb,
1005            4 => Layout::Rgba, // 4-channel CMYK; moxcms Rgba layout == CMYK layout
1006            _ => {
1007                warn!("unsupported number of components {number_components} for ICC profile");
1008
1009                return None;
1010            }
1011        };
1012
1013        // PDF spec §8.6.5.8: the default rendering intent is RelativeColorimetric.
1014        // moxcms defaults to Perceptual, which compresses in-gamut colors and
1015        // produces subtle hue shifts on CMYK→sRGB conversions. Prefer
1016        // RelativeColorimetric to match the PDF spec default and the behaviour
1017        // of Acrobat/MuPDF.
1018        //
1019        // BUT: many bundled/embedded ICC profiles — including our own built-in
1020        // CGATS001Compat-v2-micro CMYK profile — ship only a Perceptual A2B
1021        // LUT. moxcms returns UnsupportedLutRenderingIntent in that case,
1022        // which silently poisoned the entire CMYK path (profile load returns
1023        // None → default_cmyk_profile() returns None → DeviceCMYK falls back
1024        // to the PDF §10.3.5 algebraic formula, which hard-clamps rich-black
1025        // CMYK to zero and produces near-black output where Acrobat/PDFium
1026        // render colour correctly). Fall back from RelativeColorimetric to
1027        // Perceptual to Saturation on missing-LUT so the profile still works.
1028        // (#1002)
1029        let intents_to_try = [
1030            RenderingIntent::RelativeColorimetric,
1031            RenderingIntent::Perceptual,
1032            RenderingIntent::Saturation,
1033        ];
1034        let mut u8_transform = None;
1035        let mut f32_transform = None;
1036        for intent in intents_to_try {
1037            let options = TransformOptions {
1038                rendering_intent: intent,
1039                ..TransformOptions::default()
1040            };
1041            let u8_ok = src_profile
1042                .create_transform_8bit(src_layout, &dest_profile, Layout::Rgb, options)
1043                .ok();
1044            let f32_ok = src_profile
1045                .create_transform_f32(src_layout, &dest_profile, Layout::Rgb, options)
1046                .ok();
1047            if let (Some(u), Some(f)) = (u8_ok, f32_ok) {
1048                u8_transform = Some(u);
1049                f32_transform = Some(f);
1050                break;
1051            }
1052        }
1053        let u8_transform = u8_transform?;
1054        let f32_transform = f32_transform?;
1055
1056        Some(Self(Arc::new(ICCColorRepr {
1057            transform_u8: u8_transform,
1058            transform_f32: f32_transform,
1059            number_components,
1060            is_srgb,
1061            is_lab,
1062        })))
1063    }
1064
1065    fn is_srgb(&self) -> bool {
1066        self.0.is_srgb
1067    }
1068
1069    fn is_lab(&self) -> bool {
1070        self.0.is_lab
1071    }
1072}
1073
1074impl ToRgb for ICCProfile {
1075    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
1076        let mut temp = vec![0.0_f32; output.len()];
1077
1078        if self.is_lab() {
1079            // moxcms expects normalized values.
1080            let scaled = input
1081                .chunks_exact(3)
1082                .flat_map(|i| {
1083                    [
1084                        i[0] * (1.0 / 100.0),
1085                        (i[1] + 128.0) * (1.0 / 255.0),
1086                        (i[2] + 128.0) * (1.0 / 255.0),
1087                    ]
1088                })
1089                .collect::<Vec<_>>();
1090            self.0.transform_f32.transform(&scaled, &mut temp).ok()?;
1091        } else {
1092            self.0.transform_f32.transform(input, &mut temp).ok()?;
1093        };
1094
1095        for (input, output) in temp.iter().zip(output.iter_mut()) {
1096            *output = (input * 255.0 + 0.5) as u8;
1097        }
1098
1099        Some(())
1100    }
1101
1102    fn supports_u8(&self) -> bool {
1103        true
1104    }
1105
1106    fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> {
1107        // sRGB fast path: only skip the transform when input and output
1108        // buffers have matching lengths. Otherwise copy_from_slice would
1109        // panic — callers can still legitimately pass mismatched slices
1110        // (e.g. grayscale → RGB triple-up) and we must fall through to the
1111        // moxcms transform in that case. Cherry-picked surgically from
1112        // reverted #920 overhaul (commit 23b7007ba).
1113        if self.is_srgb() && input.len() == output.len() {
1114            output.copy_from_slice(input);
1115        } else {
1116            self.0.transform_u8.transform(input, output).ok()?;
1117        }
1118
1119        Some(())
1120    }
1121}
1122
1123#[inline(always)]
1124fn f32_to_u8(val: f32) -> u8 {
1125    (val * 255.0 + 0.5) as u8
1126}
1127
1128#[derive(Debug, Clone)]
1129/// A color.
1130pub struct Color {
1131    color_space: ColorSpace,
1132    components: ColorComponents,
1133    opacity: f32,
1134}
1135
1136impl Color {
1137    pub(crate) fn new(color_space: ColorSpace, components: ColorComponents, opacity: f32) -> Self {
1138        Self {
1139            color_space,
1140            components,
1141            opacity,
1142        }
1143    }
1144
1145    /// Return the color as an RGBA color.
1146    pub fn to_rgba(&self) -> AlphaColor {
1147        self.color_space
1148            .to_rgba(&self.components, self.opacity, false)
1149    }
1150
1151    /// Create a color from RGBA.
1152    pub fn from_rgba(rgba: AlphaColor) -> Self {
1153        let c = rgba.components();
1154        Self {
1155            color_space: ColorSpace::device_rgb(),
1156            components: smallvec![c[0], c[1], c[2]],
1157            opacity: c[3],
1158        }
1159    }
1160
1161    /// Create a color in the DeviceRGB color space.
1162    pub fn from_device_rgb(r: f32, g: f32, b: f32) -> Self {
1163        Self {
1164            color_space: ColorSpace::device_rgb(),
1165            components: smallvec![r, g, b],
1166            opacity: 1.0,
1167        }
1168    }
1169
1170    /// Create a color in the DeviceRGB color space with custom opacity.
1171    pub fn from_device_rgb_with_opacity(r: f32, g: f32, b: f32, opacity: f32) -> Self {
1172        Self {
1173            color_space: ColorSpace::device_rgb(),
1174            components: smallvec![r, g, b],
1175            opacity,
1176        }
1177    }
1178
1179    /// Returns the opacity of this color (0.0 = fully transparent, 1.0 = fully opaque).
1180    pub fn opacity(&self) -> f32 {
1181        self.opacity
1182    }
1183
1184    /// Return `true` if this color is expressed in the DeviceCMYK color space.
1185    pub fn is_device_cmyk(&self) -> bool {
1186        self.color_space.is_device_cmyk()
1187    }
1188
1189    /// Return the raw DeviceCMYK components, if this color is DeviceCMYK.
1190    pub fn device_cmyk_components(&self) -> Option<[f32; 4]> {
1191        if !self.color_space.is_device_cmyk() || self.components.len() != 4 {
1192            return None;
1193        }
1194
1195        Some([
1196            self.components[0],
1197            self.components[1],
1198            self.components[2],
1199            self.components[3],
1200        ])
1201    }
1202}
1203
1204pub(crate) trait ToRgb {
1205    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()>;
1206    fn supports_u8(&self) -> bool {
1207        false
1208    }
1209    fn convert_u8(&self, _: &[u8], _: &mut [u8]) -> Option<()> {
1210        unimplemented!();
1211    }
1212    fn is_none(&self) -> bool {
1213        false
1214    }
1215    fn to_alpha_color(
1216        &self,
1217        input: &[f32],
1218        mut opacity: f32,
1219        manual_scale: bool,
1220    ) -> Option<AlphaColor> {
1221        let mut output = [0; 3];
1222        self.convert_f32(input, &mut output, manual_scale)?;
1223
1224        // For separation color spaces:
1225        // "The special colourant name None shall not produce any visible output.
1226        // Painting operations in a Separation space with this colourant name
1227        // shall have no effect on the current page."
1228        if self.is_none() {
1229            opacity = 0.0;
1230        }
1231
1232        Some(AlphaColor::from_rgba8(
1233            output[0],
1234            output[1],
1235            output[2],
1236            (opacity * 255.0 + 0.5) as u8,
1237        ))
1238    }
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use pdf_syntax::object::{Array, FromBytes};
1245
1246    /// Minimal PDF bytes for a Separation colorspace array:
1247    ///   [/Separation <name> /DeviceGray <tint-function>]
1248    /// The tint function is a Type 2 exponential mapping 1 input → 1 output.
1249    fn separation_array(ink_name: &str) -> Vec<u8> {
1250        format!(
1251            "[/Separation /{ink_name} /DeviceGray \
1252             << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> ]",
1253            ink_name = ink_name
1254        )
1255        .into_bytes()
1256    }
1257
1258    fn make_separation(ink_name: &str) -> Option<Separation> {
1259        let bytes = separation_array(ink_name);
1260        let array = Array::from_bytes(&bytes)?;
1261        let cache = Cache::new();
1262        Separation::new(&array, &cache)
1263    }
1264
1265    /// PDF spec §8.6.6.4: only the literal name "None" suppresses paint.
1266    #[test]
1267    fn none_ink_is_suppressed() {
1268        let sep = make_separation("None").expect("should parse");
1269        assert!(sep.is_none(), "ink name 'None' must be suppressed");
1270    }
1271
1272    /// PANTONE spot colours must NOT be suppressed — they are real inks.
1273    #[test]
1274    fn pantone_ink_is_not_suppressed() {
1275        // PDF name encoding: spaces become #20
1276        let sep = make_separation("PANTONE#20123#20CVC").expect("should parse");
1277        assert!(!sep.is_none(), "PANTONE spot colour must not be suppressed");
1278    }
1279
1280    /// "All" is a special name meaning every device colourant, not silence.
1281    #[test]
1282    fn all_ink_is_not_suppressed() {
1283        let sep = make_separation("All").expect("should parse");
1284        assert!(!sep.is_none(), "'All' separation must not be suppressed");
1285    }
1286
1287    /// A non-None Separation must produce visible output (opacity > 0).
1288    #[test]
1289    fn pantone_produces_visible_color() {
1290        let sep = make_separation("PANTONE#20123#20CVC").expect("should parse");
1291        let cs = ColorSpace(Arc::new(ColorSpaceType::Separation(sep)));
1292        // tint = 1.0 (full ink) → tint transform maps to grayscale 0 (black)
1293        let color = cs.to_rgba(&[1.0], 1.0, false);
1294        assert!(
1295            color.to_rgba8()[3] > 0,
1296            "PANTONE ink at tint=1.0 must be opaque"
1297        );
1298    }
1299
1300    /// The "None" separation must produce a fully transparent pixel.
1301    #[test]
1302    fn none_produces_transparent_color() {
1303        let sep = make_separation("None").expect("should parse");
1304        let cs = ColorSpace(Arc::new(ColorSpaceType::Separation(sep)));
1305        let color = cs.to_rgba(&[1.0], 1.0, false);
1306        assert_eq!(
1307            color.to_rgba8()[3],
1308            0,
1309            "Separation/None ink must be fully transparent"
1310        );
1311    }
1312}