pdfrum_doc/color.rs
1//! The annotation colour value (`/C`, `/IC`, `/MK /BC`, `/MK /BG`) and the
2//! three different ways PDFium converts it to bytes.
3//!
4//! A colour array's *length* alone chooses the space — 1 grey, 3 RGB, 4 CMYK
5//! — and every other length, including zero, means "no colour at all". That
6//! is not a fallback for damage: `/IC []` deliberately says "do not fill".
7//!
8//! ```
9//! use pdfrum_doc::color::Color;
10//! use pdfrum_object::{Array, Object};
11//!
12//! let rgb = Array::of([0.25, 0.5, 1.0].map(Object::from));
13//! assert_eq!(Color::from_array(&rgb), Color::Rgb(0.25, 0.5, 1.0));
14//! // Two components name no space.
15//! let two = Array::of([0.25, 0.5].map(Object::from));
16//! assert_eq!(Color::from_array(&two), Color::Transparent);
17//! ```
18
19// Every conversion below narrows on purpose: these reproduce a C float-to-
20// integer conversion, whose truncation toward zero is the observable
21// behaviour, not an approximation of it.
22#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
23
24use pdfrum_object::Array;
25
26/// A colour in whichever device space its component count named.
27#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub enum Color {
29 /// No colour: the caller emits no colour operator at all.
30 #[default]
31 Transparent,
32 /// `DeviceGray`.
33 Gray(f32),
34 /// `DeviceRGB`.
35 Rgb(f32, f32, f32),
36 /// `DeviceCMYK`.
37 Cmyk(f32, f32, f32, f32),
38}
39
40impl Color {
41 /// Reads a colour array, dispatching purely on its length.
42 ///
43 /// Components come through the non-resolving numeric accessor, so a
44 /// missing or mistyped element reads as `0.0` rather than rejecting the
45 /// array.
46 ///
47 /// ```
48 /// use pdfrum_doc::color::Color;
49 /// use pdfrum_object::{Array, Object};
50 ///
51 /// let grey = Array::of([0.5].map(Object::from));
52 /// assert_eq!(Color::from_array(&grey), Color::Gray(0.5));
53 /// // An empty array says "no colour", not "damaged".
54 /// assert_eq!(Color::from_array(&Array::of([])), Color::Transparent);
55 /// ```
56 #[must_use]
57 pub fn from_array(array: &Array) -> Color {
58 let at = |i: usize| array.number_at_or_zero(i);
59 match array.len() {
60 1 => Color::Gray(at(0)),
61 3 => Color::Rgb(at(0), at(1), at(2)),
62 4 => Color::Cmyk(at(0), at(1), at(2), at(3)),
63 _ => Color::Transparent,
64 }
65 }
66
67 /// Component-wise equality within `1e-4`.
68 ///
69 /// Deliberately not `PartialEq`: the derived one stays exact so a test
70 /// The RGB bytes the `--annot` dump prints.
71 ///
72 /// CMYK converts multiplicatively (`255·(1−c)·(1−k)`), which is **not**
73 /// the formula `Color::mk_rgb_bytes` uses for the same colour in a
74 /// widget's `/MK` dictionary. Both exist upstream; keeping them named
75 /// apart is the only defence against unifying them.
76 ///
77 /// Negative components saturate at zero rather than wrapping, which is
78 /// what the C++'s float-to-`unsigned` conversion does in practice.
79 ///
80 /// ```
81 /// use pdfrum_doc::color::Color;
82 ///
83 /// assert_eq!(Color::Rgb(1.0, 0.0, 0.5).annot_rgb_bytes(), (255, 0, 127));
84 /// // CMYK converts multiplicatively here, not the way `/MK` colours do.
85 /// assert_eq!(Color::Cmyk(0.0, 0.0, 0.0, 0.0).annot_rgb_bytes(), (255, 255, 255));
86 /// ```
87 #[must_use]
88 pub fn annot_rgb_bytes(self) -> (u32, u32, u32) {
89 let byte = |v: f32| {
90 let scaled = v * 255.0;
91 if scaled <= 0.0 {
92 0
93 } else if !scaled.is_finite() || scaled >= 4_294_967_296.0 {
94 u32::MAX
95 } else {
96 scaled as u32
97 }
98 };
99 match self {
100 Color::Transparent => (0, 0, 0),
101 Color::Gray(g) => (byte(g), byte(g), byte(g)),
102 Color::Rgb(r, g, b) => (byte(r), byte(g), byte(b)),
103 Color::Cmyk(c, m, y, k) => (
104 byte((1.0 - c) * (1.0 - k)),
105 byte((1.0 - m) * (1.0 - k)),
106 byte((1.0 - y) * (1.0 - k)),
107 ),
108 }
109 }
110
111 /// The RGB bytes a widget's `/MK` colour converts to.
112 ///
113 /// CMYK converts subtractively here (`(1 − min(1, c+k))·255`), and every
114 /// channel rounds by adding a half before truncating. See
115 /// [`Color::annot_rgb_bytes`] for the other formula.
116 #[must_use]
117 #[cfg(test)]
118 pub(crate) fn mk_rgb_bytes(self) -> (u8, u8, u8) {
119 let byte = |v: f32| {
120 let scaled = v * 255.0 + 0.5;
121 if scaled <= 0.0 {
122 0
123 } else if scaled >= 255.0 {
124 255
125 } else {
126 scaled as u8
127 }
128 };
129 match self {
130 Color::Transparent => (0, 0, 0),
131 Color::Gray(g) => (byte(g), byte(g), byte(g)),
132 Color::Rgb(r, g, b) => (byte(r), byte(g), byte(b)),
133 Color::Cmyk(c, m, y, k) => (
134 byte(1.0 - (c + k).min(1.0)),
135 byte(1.0 - (m + k).min(1.0)),
136 byte(1.0 - (y + k).min(1.0)),
137 ),
138 }
139 }
140}
141
142/// Builds an RGB colour from 0–255 bytes, the way the hard-coded widget
143/// chrome colours are written upstream.
144#[must_use]
145#[cfg(test)]
146pub(crate) fn rgb_bytes(r: u8, g: u8, b: u8) -> Color {
147 Color::Rgb(
148 f32::from(r) / 255.0,
149 f32::from(g) / 255.0,
150 f32::from(b) / 255.0,
151 )
152}
153
154#[cfg(test)]
155mod tests {
156 use super::{Color, rgb_bytes};
157 use pdfrum_object::{Array, Object};
158
159 fn array(values: &[f32]) -> Array {
160 Array::of(values.iter().copied().map(Object::from))
161 }
162
163 #[test]
164 fn length_alone_chooses_the_space() {
165 assert_eq!(Color::from_array(&array(&[])), Color::Transparent);
166 assert_eq!(Color::from_array(&array(&[0.5])), Color::Gray(0.5));
167 assert_eq!(Color::from_array(&array(&[0.5, 0.25])), Color::Transparent);
168 assert_eq!(
169 Color::from_array(&array(&[0.0, 0.5, 1.0])),
170 Color::Rgb(0.0, 0.5, 1.0)
171 );
172 assert_eq!(
173 Color::from_array(&array(&[0.0, 0.1, 0.2, 0.3])),
174 Color::Cmyk(0.0, 0.1, 0.2, 0.3)
175 );
176 assert_eq!(
177 Color::from_array(&array(&[0.0, 0.1, 0.2, 0.3, 0.4])),
178 Color::Transparent
179 );
180 }
181
182 #[test]
183 fn a_mistyped_component_reads_as_zero() {
184 let mixed = Array::of([
185 Object::Name(pdfrum_object::Name::from("Oops")),
186 Object::from(0.5_f32),
187 Object::from(1.0_f32),
188 ]);
189 assert_eq!(Color::from_array(&mixed), Color::Rgb(0.0, 0.5, 1.0));
190 }
191
192 #[test]
193 fn the_two_cmyk_formulas_differ() {
194 let c = Color::Cmyk(0.5, 0.0, 0.0, 0.5);
195 // Multiplicative: 255 * 0.5 * 0.5 = 63.75 -> 63.
196 assert_eq!(c.annot_rgb_bytes().0, 63);
197 // Subtractive: (1 - min(1, 1.0)) * 255 + 0.5 = 0.5 -> 0.
198 assert_eq!(c.mk_rgb_bytes().0, 0);
199 }
200
201 #[test]
202 fn negative_components_saturate_rather_than_wrap() {
203 assert_eq!(Color::Rgb(-1.0, 0.0, 1.0).annot_rgb_bytes(), (0, 0, 255));
204 }
205
206 #[test]
207 fn byte_constructor_round_trips_through_the_annot_formula() {
208 assert_eq!(rgb_bytes(220, 220, 220).annot_rgb_bytes(), (220, 220, 220));
209 assert_eq!(rgb_bytes(0, 51, 113).annot_rgb_bytes(), (0, 51, 113));
210 }
211}