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}