Skip to main content

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}