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}