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