Skip to main content

pdfrum_page/color/
mod.rs

1//! Colour spaces and the values that live in them (ISO 32000-1 §8.6).
2//!
3//! One enum for all eleven families, with the per-family maths in a module
4//! each. Two things about this design are load-bearing:
5//!
6//! - **There are two conversion paths, and they disagree.** [`to_rgb`] is the
7//!   scalar path a vector fill takes; [`translate_image_line`] is the bulk
8//!   path an image takes. For most families they agree modulo byte order, but
9//!   `CalRGB` ignores its gamma, matrix and white point in bulk, and `Lab`
10//!   scales its inputs differently. Both halves are ported because the oracle
11//!   renders both.
12//! - **`Pattern` has no `to_rgb`.** The C++ makes it an unreachable
13//!   assertion; here the case is simply not representable, and a pattern's
14//!   colour comes from [`PatternValue`] instead.
15//!
16//! [`to_rgb`]: ColorSpace::to_rgb
17//! [`translate_image_line`]: ColorSpace::translate_image_line
18
19mod cie;
20mod cmyk_table;
21mod device;
22mod icc;
23mod indexed;
24mod load;
25mod special;
26mod srgb_table;
27mod value;
28
29pub(crate) use cie::{CalGray, CalRgb, Lab};
30/// The Adobe CMYK -> sRGB table lookup, byte in and byte out.
31///
32/// Re-exported because the image path needs it without the float wrapper
33/// around it: `Pixels::sample_bytes` reaches it directly, and the two spellings
34/// are proved equal exhaustively rather than assumed.
35pub use device::adobe_cmyk_to_srgb;
36pub(crate) use icc::{IccBased, IccProfile, is_valid_icc_components};
37pub use icc::{cmyk_profile_bytes, srgb_profile_bytes};
38pub(crate) use indexed::Indexed;
39pub(crate) use load::ColorSpaceCache;
40pub use load::load_colorspace;
41pub(crate) use special::{DeviceN, MAX_PATTERN_COMPONENTS};
42pub use special::{PatternSpace, Separation};
43pub use value::{ColorValue, PatternValue, SetComponentsError};
44
45/// A colour in the device's RGB space, each channel nominally in `0..=1`.
46///
47/// Not clamped on construction: several conversion paths deliberately return
48/// out-of-range values (`CalGray` passes its input through untouched) and the
49/// consumer clamps where PDFium clamps.
50#[derive(Debug, Clone, Copy, PartialEq, Default)]
51pub struct Rgb {
52    /// Red.
53    pub r: f32,
54    /// Green.
55    pub g: f32,
56    /// Blue.
57    pub b: f32,
58}
59
60impl Rgb {
61    /// Opaque black, the colour a broken space paints.
62    pub const BLACK: Self = Self {
63        r: 0.0,
64        g: 0.0,
65        b: 0.0,
66    };
67
68    /// The eight-bit encoding used for colour *references*: clamp, then round
69    /// to nearest.
70    ///
71    /// Contrast [`Self::to_bytes_truncating`], which the bulk image path
72    /// uses. The two differ by one on roughly half of all inputs, so calling
73    /// the wrong one is a visible bug.
74    #[must_use]
75    pub fn to_bytes(self) -> [u8; 3] {
76        #[expect(
77            clippy::cast_possible_truncation,
78            clippy::cast_sign_loss,
79            reason = "the clamp bounds the product to 0..=255"
80        )]
81        let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
82        [enc(self.r), enc(self.g), enc(self.b)]
83    }
84
85    /// The eight-bit encoding the image scanline path uses: clamp, then
86    /// truncate.
87    #[must_use]
88    pub fn to_bytes_truncating(self) -> [u8; 3] {
89        #[expect(
90            clippy::cast_possible_truncation,
91            clippy::cast_sign_loss,
92            reason = "the clamp bounds the product to 0..=255"
93        )]
94        let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u8;
95        [enc(self.r), enc(self.g), enc(self.b)]
96    }
97}
98
99/// The eleven colour space families, with the integer tags PDFium exposes
100/// through its public API.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum Family {
103    /// A space that failed to load.
104    Unknown = 0,
105    /// `DeviceGray`.
106    DeviceGray = 1,
107    /// `DeviceRGB`.
108    DeviceRgb = 2,
109    /// `DeviceCMYK`.
110    DeviceCmyk = 3,
111    /// `CalGray`.
112    CalGray = 4,
113    /// `CalRGB`.
114    CalRgb = 5,
115    /// `Lab`.
116    Lab = 6,
117    /// `ICCBased`.
118    IccBased = 7,
119    /// `Separation`.
120    Separation = 8,
121    /// `DeviceN`.
122    DeviceN = 9,
123    /// `Indexed`.
124    Indexed = 10,
125    /// `Pattern`.
126    Pattern = 11,
127}
128
129/// A loaded colour space.
130///
131/// Cheap to clone: the heavy members — ICC profiles, tint transforms — are
132/// behind `Arc`, and palettes are boxed slices.
133#[derive(Debug, Clone, PartialEq)]
134#[non_exhaustive]
135pub enum ColorSpace {
136    /// One component: a grey level.
137    DeviceGray,
138    /// Three components: red, green, blue.
139    DeviceRgb,
140    /// Four components: cyan, magenta, yellow, black.
141    DeviceCmyk,
142    /// A calibrated grey space whose calibration is parsed and then ignored.
143    CalGray(Box<CalGray>),
144    /// A calibrated RGB space.
145    CalRgb(Box<CalRgb>),
146    /// A CIE 1976 L\*a\*b\* space.
147    Lab(Box<Lab>),
148    /// A space defined by an embedded ICC profile.
149    IccBased(Box<IccBased>),
150    /// A palette over some base space.
151    Indexed(Box<Indexed>),
152    /// One colorant driving an alternate space through a tint transform.
153    Separation(Box<Separation>),
154    /// Several colorants driving an alternate space.
155    DeviceN(Box<DeviceN>),
156    /// Colour supplied by a pattern rather than by components.
157    Pattern(Box<PatternSpace>),
158}
159
160impl ColorSpace {
161    /// Which family this is.
162    #[must_use]
163    pub fn family(&self) -> Family {
164        match self {
165            Self::DeviceGray => Family::DeviceGray,
166            Self::DeviceRgb => Family::DeviceRgb,
167            Self::DeviceCmyk => Family::DeviceCmyk,
168            Self::CalGray(_) => Family::CalGray,
169            Self::CalRgb(_) => Family::CalRgb,
170            Self::Lab(_) => Family::Lab,
171            Self::IccBased(_) => Family::IccBased,
172            Self::Indexed(_) => Family::Indexed,
173            Self::Separation(_) => Family::Separation,
174            Self::DeviceN(_) => Family::DeviceN,
175            Self::Pattern(_) => Family::Pattern,
176        }
177    }
178
179    /// How many components a colour in this space carries.
180    ///
181    /// `Separation` and `Indexed` always report **1**; `ICCBased` reports its
182    /// `/N` rather than whatever the profile says; `DeviceN` reports its
183    /// colorant count with no cap.
184    #[must_use]
185    pub fn n_components(&self) -> usize {
186        match self {
187            Self::DeviceGray | Self::CalGray(_) | Self::Indexed(_) | Self::Separation(_) => 1,
188            Self::DeviceRgb | Self::CalRgb(_) | Self::Lab(_) => 3,
189            Self::DeviceCmyk => 4,
190            Self::IccBased(icc) => usize::from(icc.n),
191            Self::DeviceN(cs) => cs.names.len(),
192            Self::Pattern(cs) => cs.n_components(),
193        }
194    }
195
196    /// Whether the space's components are indices or tints rather than
197    /// colour: `Separation`, `DeviceN`, `Indexed`, `Pattern`.
198    ///
199    /// Several validation paths refuse a special space where a colour is
200    /// required — a shading's alternate, an `ICCBased`'s `/Alternate`.
201    #[must_use]
202    pub fn is_special(&self) -> bool {
203        matches!(
204            self,
205            Self::Separation(_) | Self::DeviceN(_) | Self::Indexed(_) | Self::Pattern(_)
206        )
207    }
208
209    /// Whether an image's samples in this space have to be **run through the
210    /// space** before they are colour.
211    ///
212    /// Every family could reach a colour through the scalar conversion, but
213    /// most have a bulk shortcut that makes the trip unnecessary — and taking
214    /// the scalar conversion anyway would be a divergence rather than a fix,
215    /// because two of those shortcuts are *not* the scalar conversion.
216    /// `CalGray` copies the grey byte into all three channels; `CalRGB` is a
217    /// channel reversal that drops gamma and matrix entirely; `ICCBased` runs
218    /// the profile over bytes; and `Lab` rescales `L*a*b*` out of the **byte**
219    /// domain rather than out of the decoded component range. `Indexed` is
220    /// handled before this by its palette, and `Pattern` never carries image
221    /// samples at all.
222    ///
223    /// What is left is `Separation` and `DeviceN`, whose samples are *tints*
224    /// driving a tint transform and have no device reading whatsoever. Those
225    /// take the generic per-pixel path, and the palette precomputation over
226    /// `1 << bits` entries when the sample depth allows it. ISO 32000-1
227    /// §8.6.6.4 and §8.6.6.5 say the same: the components are colorant tints,
228    /// and the tint transform is what turns them into colour.
229    ///
230    /// Crate-internal: this is `unpack`'s dispatch rule, not a fact about the
231    /// space a caller outside the image build has any use for.
232    #[must_use]
233    pub(crate) fn needs_image_conversion(&self) -> bool {
234        matches!(self, Self::Separation(_) | Self::DeviceN(_))
235    }
236
237    /// Whether the space is a plain additive or subtractive colour space,
238    /// which decides whether a soft mask may take its backdrop from it.
239    #[must_use]
240    pub fn is_normal(&self) -> bool {
241        match self {
242            Self::DeviceGray
243            | Self::DeviceRgb
244            | Self::DeviceCmyk
245            | Self::CalGray(_)
246            | Self::CalRgb(_) => true,
247            Self::IccBased(icc) => icc.is_normal(),
248            _ => false,
249        }
250    }
251
252    /// Convert components to RGB, taking the vector-fill path.
253    ///
254    /// A space that cannot produce a colour for these components — an out of
255    /// range `Indexed` entry, a `/None` `Separation` — paints **black**,
256    /// which is what every caller of the C++'s `GetRGBOrZerosOnError` sees.
257    /// Use [`Self::try_to_rgb`] when the distinction matters.
258    #[must_use]
259    pub fn to_rgb(&self, comps: &[f32]) -> Rgb {
260        self.try_to_rgb(comps).unwrap_or(Rgb::BLACK)
261    }
262
263    /// Convert components, distinguishing "black" from "no colour at all".
264    #[must_use]
265    pub fn try_to_rgb(&self, comps: &[f32]) -> Option<Rgb> {
266        match self {
267            Self::DeviceGray => Some(device::gray_to_rgb(comps)),
268            Self::DeviceRgb => Some(device::rgb_to_rgb(comps)),
269            Self::DeviceCmyk => Some(device::cmyk_to_rgb(comps)),
270            Self::CalGray(_) => Some(cie::cal_gray_to_rgb(comps)),
271            Self::CalRgb(cs) => Some(cie::cal_rgb_to_rgb(cs, comps)),
272            Self::Lab(_) => Some(cie::lab_to_rgb(comps)),
273            Self::IccBased(icc) => Some(icc.to_rgb(comps)),
274            Self::Indexed(cs) => cs.to_rgb(comps),
275            Self::Separation(cs) => cs.to_rgb(comps),
276            Self::DeviceN(cs) => cs.to_rgb(comps),
277            // A pattern's colour is not a function of its components; see
278            // `PatternValue`.
279            Self::Pattern(_) => None,
280        }
281    }
282
283    /// The initial value and legal interval of component `index`.
284    ///
285    /// The initial *colour* of a space is these values for each component:
286    /// zero everywhere except `Separation` and `DeviceN`, which start at full
287    /// colorant.
288    #[must_use]
289    pub fn default_value(&self, index: usize) -> (f32, f32, f32) {
290        match self {
291            Self::Lab(lab) => lab.default_value(index),
292            Self::Indexed(cs) => (0.0, 0.0, f32::from(cs.max_index)),
293            // Full colorant, unlike every other family's zero.
294            Self::Separation(_) | Self::DeviceN(_) => (1.0, 0.0, 1.0),
295            _ => (0.0, 0.0, 1.0),
296        }
297    }
298
299    /// The space's initial colour: one [`Self::default_value`] per component.
300    #[must_use]
301    pub fn default_color(&self) -> Vec<f32> {
302        (0..self.n_components())
303            .map(|i| self.default_value(i).0)
304            .collect()
305    }
306
307    /// Convert a run of image samples to **B, G, R** triples.
308    ///
309    /// This is the bulk path, and it is *not* `to_rgb` in a loop for every
310    /// family — see the module docs. `samples` holds `pixels *
311    /// n_components()` bytes; `dest` receives `pixels * 3`.
312    ///
313    /// `trans_mask` selects `DeviceCMYK`'s second formula, the one that is
314    /// unreachable from the scalar path; every other family ignores it.
315    pub fn translate_image_line(
316        &self,
317        dest: &mut [u8],
318        samples: &[u8],
319        pixels: usize,
320        trans_mask: bool,
321    ) {
322        match self {
323            // The byte replicated, written R,G,B — the one family that does
324            // not swap.
325            Self::DeviceGray | Self::CalGray(_) => {
326                for i in 0..pixels {
327                    let v = samples.get(i).copied().unwrap_or(0);
328                    if let Some(px) = dest.get_mut(i * 3..i * 3 + 3) {
329                        px.fill(v);
330                    }
331                }
332            }
333            // A plain red-blue swap. `CalRGB` lands here too, which is where
334            // its gamma, matrix and white point are silently dropped.
335            Self::DeviceRgb | Self::CalRgb(_) => reverse_rgb(dest, samples, pixels),
336            Self::DeviceCmyk => {
337                Self::translate_cmyk_line(dest, samples, pixels, trans_mask);
338            }
339            // The same maths as the scalar path but on a different input
340            // encoding: L* spans the byte range, a* and b* are offset by 128.
341            Self::Lab(_) => {
342                for i in 0..pixels {
343                    let Some(&[l, a, b]) = triple(samples, i) else {
344                        continue;
345                    };
346                    let comps = [
347                        f32::from(l) * 100.0 / 255.0,
348                        f32::from(a) - 128.0,
349                        f32::from(b) - 128.0,
350                    ];
351                    write_bgr(dest, i, cie::lab_to_rgb(&comps));
352                }
353            }
354            Self::IccBased(icc) => {
355                if icc.profile.is_srgb() {
356                    reverse_rgb(dest, samples, pixels);
357                } else if icc.profile.is_supported() {
358                    self.translate_generic_line(dest, samples, pixels);
359                } else if let Some(base) = &icc.base {
360                    base.translate_image_line(dest, samples, pixels, false);
361                } else {
362                    for i in 0..pixels {
363                        write_bgr(dest, i, Rgb::BLACK);
364                    }
365                }
366            }
367            _ => self.translate_generic_line(dest, samples, pixels),
368        }
369    }
370
371    /// `DeviceCMYK`'s two bulk formulas.
372    #[expect(
373        clippy::many_single_char_names,
374        reason = "cyan, magenta, yellow and black are single-letter by convention"
375    )]
376    fn translate_cmyk_line(dest: &mut [u8], samples: &[u8], pixels: usize, trans_mask: bool) {
377        for i in 0..pixels {
378            let Some(&[c8, m8, y8, k8]) = samples
379                .get(i * 4..i * 4 + 4)
380                .and_then(|s| <&[u8; 4]>::try_from(s).ok())
381            else {
382                continue;
383            };
384            let (c, m, y, k) = (u32::from(c8), u32::from(m8), u32::from(y8), u32::from(k8));
385            #[expect(
386                clippy::cast_possible_truncation,
387                reason = "every arm's arithmetic stays within a byte"
388            )]
389            let bgr = if trans_mask {
390                // The naive un-inversion, reachable only from an image whose
391                // group colorspace is also CMYK. Note this arm alone leaves
392                // cyan driving the *first* byte, where the other two swap it
393                // with yellow.
394                let kk = 255 - k;
395                [
396                    (((255 - c) * kk) / 255) as u8,
397                    (((255 - m) * kk) / 255) as u8,
398                    (((255 - y) * kk) / 255) as u8,
399                ]
400            } else {
401                let [r, g, b] = device::adobe_cmyk_to_srgb(c8, m8, y8, k8);
402                [b, g, r]
403            };
404            write_bytes(dest, i, bgr);
405        }
406    }
407
408    /// The generic per-pixel path: normalize, convert, write BGR truncated.
409    ///
410    /// `Indexed` divides by **1** rather than 255, so its samples reach
411    /// `to_rgb` as raw indices; every other family normalizes.
412    fn translate_generic_line(&self, dest: &mut [u8], samples: &[u8], pixels: usize) {
413        let n = self.n_components();
414        let divisor = if matches!(self, Self::Indexed(_)) {
415            1.0
416        } else {
417            255.0
418        };
419        let mut comps = vec![0.0f32; n.max(1)];
420        for i in 0..pixels {
421            for (j, slot) in comps.iter_mut().enumerate() {
422                *slot = f32::from(samples.get(i * n + j).copied().unwrap_or(0)) / divisor;
423            }
424            // A failed conversion renders black rather than skipping.
425            write_bgr(dest, i, self.to_rgb(&comps));
426        }
427    }
428}
429
430/// Write one pixel as B, G, R with the truncating encoding.
431fn write_bgr(dest: &mut [u8], index: usize, rgb: Rgb) {
432    let [r, g, b] = rgb.to_bytes_truncating();
433    write_bytes(dest, index, [b, g, r]);
434}
435
436/// Write three already-encoded bytes at pixel `index`.
437fn write_bytes(dest: &mut [u8], index: usize, bytes: [u8; 3]) {
438    if let Some(px) = dest.get_mut(index * 3..index * 3 + 3) {
439        px.copy_from_slice(&bytes);
440    }
441}
442
443/// The three bytes of pixel `index`, when the slice holds them.
444fn triple(samples: &[u8], index: usize) -> Option<&[u8; 3]> {
445    samples
446        .get(index * 3..index * 3 + 3)
447        .and_then(|s| <&[u8; 3]>::try_from(s).ok())
448}
449
450/// A red-blue swap, which several families' bulk path reduces to.
451fn reverse_rgb(dest: &mut [u8], samples: &[u8], pixels: usize) {
452    for i in 0..pixels {
453        let Some(&[r, g, b]) = triple(samples, i) else {
454            continue;
455        };
456        write_bytes(dest, i, [b, g, r]);
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    // Test fixtures quote the oracle's own vectors, compare floats exactly
463    // where the behaviour being pinned is exact, and index arrays whose
464    // length the fixture itself fixes.
465    #![allow(
466        clippy::unreadable_literal,
467        clippy::float_cmp,
468        clippy::indexing_slicing,
469        clippy::cast_precision_loss,
470        clippy::cast_possible_truncation,
471        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
472    )]
473
474    use super::{ColorSpace, Family, Rgb};
475
476    /// The bulk `DeviceCMYK` arm this crate runs, and the record of the one
477    /// it does not.
478    ///
479    /// Pins the reachable bulk `DeviceCMYK` arm, the Adobe table. The
480    /// *other* arm — the oracle's standard-conversion formula — is recomputed
481    /// here rather than called, because it is not implemented:
482    /// `device::cmyk_to_rgb` carries the proof that it can never run in the
483    /// oracle. It is asserted to disagree, so the record stays falsifiable.
484    #[test]
485    fn the_bulk_cmyk_arm_is_the_adobe_table() {
486        let cs = ColorSpace::DeviceCmyk;
487        // One saturated pixel: c=128, m=64, y=0, k=64.
488        let src = [128u8, 64, 0, 64];
489
490        let mut dest = [0u8; 3];
491        cs.translate_image_line(&mut dest, &src, 1, false);
492        // The Adobe table's own answer, as bytes in B, G, R order.
493        let [r, g, b] = super::device::adobe_cmyk_to_srgb(128, 64, 0, 64);
494        assert_eq!(dest, [b, g, r]);
495
496        // What the inert std arm would have written. The oracle assigns
497        // `blue = 255 - min(255, cyan + k)` into an `FX_RGB_STRUCT`, whose
498        // fields are laid out **red, green, blue** (`fx_dib.h:53`) — so cyan
499        // lands in the *last* byte and yellow in the first. Byte 0 =
500        // 255-min(255,0+64) = 191, byte 1 = 255-min(255,64+64) = 127, byte 2 =
501        // 255-min(255,128+64) = 63.
502        let would_have_been = [191u8, 127, 63];
503        assert_ne!(
504            dest, would_have_been,
505            "the Adobe table must not agree with the naive formula here"
506        );
507    }
508
509    /// `trans_mask` takes the second formula, the one arm where cyan drives
510    /// the *first* byte rather than being swapped with yellow.
511    #[test]
512    fn trans_mask_takes_the_naive_un_inversion() {
513        let cs = ColorSpace::DeviceCmyk;
514        let src = [128u8, 64, 0, 64];
515        let mut a = [0u8; 3];
516        cs.translate_image_line(&mut a, &src, 1, true);
517        // k' = 255-64 = 191; ((255-128)*191)/255 = 95, ((255-64)*191)/255 = 143,
518        // ((255-0)*191)/255 = 191.
519        assert_eq!(a, [95, 143, 191]);
520    }
521
522    #[test]
523    fn component_counts_match_the_families() {
524        assert_eq!(ColorSpace::DeviceGray.n_components(), 1);
525        assert_eq!(ColorSpace::DeviceRgb.n_components(), 3);
526        assert_eq!(ColorSpace::DeviceCmyk.n_components(), 4);
527        assert_eq!(ColorSpace::DeviceGray.family(), Family::DeviceGray);
528        assert!(!ColorSpace::DeviceRgb.is_special());
529        assert!(ColorSpace::DeviceRgb.is_normal());
530    }
531
532    #[test]
533    fn cal_gray_translates_one_byte_per_pixel_replicated() {
534        // The oracle's `CPDFCalRGBTest`/`CalGray` vector.
535        let cs = ColorSpace::CalGray(Box::new(super::CalGray {
536            white_point: [0.9505, 1.0, 1.089],
537            black_point: [0.0; 3],
538            gamma: 1.0,
539        }));
540        let src = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128];
541        let mut dest = [0u8; 12];
542        cs.translate_image_line(&mut dest, &src, 4, false);
543        assert_eq!(dest, [255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
544    }
545
546    #[test]
547    fn cal_rgb_bulk_is_only_a_red_blue_swap() {
548        // The test that pins the scalar/bulk disagreement: gamma, matrix and
549        // white point are all ignored here.
550        let cs = ColorSpace::CalRgb(Box::new(super::CalRgb {
551            white_point: [0.9505, 1.0, 1.089],
552            black_point: [0.0; 3],
553            gamma: Some([2.2, 2.2, 2.2]),
554            matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
555        }));
556        let src = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128];
557        let mut dest = [0u8; 12];
558        cs.translate_image_line(&mut dest, &src, 4, false);
559        assert_eq!(dest, [0, 0, 255, 0, 255, 0, 255, 0, 0, 128, 128, 128]);
560    }
561
562    #[test]
563    fn rgb_encodings_round_and_truncate_differently() {
564        let c = Rgb {
565            r: 0.5,
566            g: 0.5,
567            b: 0.5,
568        };
569        assert_eq!(c.to_bytes(), [128, 128, 128]);
570        assert_eq!(c.to_bytes_truncating(), [127, 127, 127]);
571        // Both clamp.
572        let c = Rgb {
573            r: -1.0,
574            g: 2.0,
575            b: 0.0,
576        };
577        assert_eq!(c.to_bytes(), [0, 255, 0]);
578        assert_eq!(c.to_bytes_truncating(), [0, 255, 0]);
579    }
580
581    #[test]
582    fn separation_and_device_n_start_at_full_colorant() {
583        let sep = ColorSpace::Separation(Box::new(super::Separation {
584            none: false,
585            alternate: Some(Box::new(ColorSpace::DeviceGray)),
586            tint: None,
587        }));
588        assert_eq!(sep.default_color(), vec![1.0]);
589        assert_eq!(ColorSpace::DeviceRgb.default_color(), vec![0.0, 0.0, 0.0]);
590        assert_eq!(ColorSpace::DeviceCmyk.default_color(), vec![0.0; 4]);
591    }
592
593    #[test]
594    fn pattern_has_no_scalar_colour() {
595        let cs = ColorSpace::Pattern(Box::default());
596        assert!(cs.try_to_rgb(&[0.5]).is_none());
597        // …and the fallible-free wrapper paints black rather than panicking.
598        assert_eq!(cs.to_rgb(&[0.5]), Rgb::BLACK);
599    }
600}