Skip to main content

pdf_interpret/
color.rs

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