Skip to main content

pdfrum_render/
color.rs

1//! Turning a page object's `ColorValue` into the 32-bit colour a device draws
2//! with.
3//!
4//! Four details in that pipeline are pixel-visible and none of them is what a
5//! re-derivation would produce: the alpha is truncated from `alpha * 255`, not
6//! rounded; the transfer function applies to the colour before any grayscale
7//! translation; a missing colour inherits from the enclosing render state
8//! rather than defaulting to black; and the invisibility sentinel is the whole
9//! 32-bit word `0xFFFFFFFF`, which a resolved colour can never be.
10//!
11//! That last one is the trap: a resolved colour occupies the low 24 bits, so
12//! a genuinely white fill is `0x00FFFFFF`, while `0xFFFFFFFF` is produced only
13//! when nothing resolved and by the pattern fallback. Testing the *colour* for
14//! white instead of the word for the sentinel makes every white object in the
15//! corpus paint nothing.
16
17use pdfrum_page::{ColorValue, Rgb};
18
19use crate::options::{ColorMode, RenderOptions};
20use crate::pixmap::alpha_byte_truncating;
21use crate::transfer::TransferFunc;
22
23/// Which kind of object a colour is being resolved for. A forced-colour
24/// scheme replaces path and text colours only; images, shadings and forms
25/// keep their own and are then not gray-translated either.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ObjectKind {
28    /// A path object, filled or stroked.
29    Path,
30    /// A text object.
31    Text,
32    /// An image, shading or form — colour passes through untouched.
33    Other,
34}
35
36/// A resolved draw colour: straight (non-premultiplied) RGB plus an alpha.
37///
38/// `Hash` because a stencil's ink is part of a rendered image's cache key
39/// (`crate::imagecache::PixmapRequest`): four bytes with a derived `Eq`, so
40/// the derived hash agrees with it by construction.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub struct Argb {
43    /// Alpha, `0` fully transparent.
44    pub a: u8,
45    /// Red.
46    pub r: u8,
47    /// Green.
48    pub g: u8,
49    /// Blue.
50    pub b: u8,
51}
52
53impl Argb {
54    /// Fully transparent — also the `0xFFFFFFFF` sentinel's result.
55    pub const TRANSPARENT: Self = Self {
56        a: 0,
57        r: 0,
58        g: 0,
59        b: 0,
60    };
61
62    /// Opaque black.
63    pub const BLACK: Self = Self {
64        a: 255,
65        r: 0,
66        g: 0,
67        b: 0,
68    };
69
70    /// An opaque colour from RGB bytes.
71    #[must_use]
72    pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
73        Self { a: 255, r, g, b }
74    }
75
76    /// Whether anything would be painted. PDFium tests the whole 32-bit word
77    /// (`if (fill_color || stroke_color)`), so a colour of exactly
78    /// `0x00000000` skips the draw — which is the same predicate.
79    #[must_use]
80    pub fn is_invisible(self) -> bool {
81        self.a == 0 && self.r == 0 && self.g == 0 && self.b == 0
82    }
83
84    /// As a `peniko::Color`, the vocabulary both backends speak.
85    #[must_use]
86    pub fn to_peniko(self) -> peniko::Color {
87        peniko::Color::from_rgba8(self.r, self.g, self.b, self.a)
88    }
89
90    /// The same colour with a different alpha.
91    #[must_use]
92    pub const fn with_alpha(self, a: u8) -> Self {
93        Self { a, ..self }
94    }
95}
96
97/// `gray = (b*11 + g*59 + r*30) / 100`, integer and truncating.
98///
99/// Integer, truncating, **NTSC weights on a 0..100 scale** — not Rec. 709 and
100/// not floating point. Both rasterizers ship a luminance helper using BT.709
101/// coefficients; neither may be used where this formula is meant.
102#[must_use]
103pub fn rgb_to_gray(r: u8, g: u8, b: u8) -> u8 {
104    // The weights sum to 100, so the maximum numerator is 255 * 100 and the
105    // truncating divide lands in 0..=255: the narrowing is exact.
106    #[expect(
107        clippy::cast_possible_truncation,
108        reason = "weights summing to 100 bound the quotient to 0..=255"
109    )]
110    let gray = ((u32::from(b) * 11 + u32::from(g) * 59 + u32::from(r) * 30) / 100) as u8;
111    gray
112}
113
114/// Identity in normal and alpha modes, a gray collapse otherwise.
115#[must_use]
116fn translate_color(mode: ColorMode, c: Argb) -> Argb {
117    match mode {
118        ColorMode::Normal | ColorMode::Alpha => c,
119        ColorMode::Gray | ColorMode::Forced(_) => {
120            let g = rgb_to_gray(c.r, c.g, c.b);
121            Argb {
122                a: c.a,
123                r: g,
124                g,
125                b: g,
126            }
127        }
128    }
129}
130
131/// `TranslateObjectFillColor` / `TranslateObjectStrokeColor`.
132#[must_use]
133fn translate_object_color(opts: &RenderOptions, c: Argb, kind: ObjectKind, stroking: bool) -> Argb {
134    let ColorMode::Forced(scheme) = opts.color_mode else {
135        return translate_color(opts.color_mode, c);
136    };
137    let replacement = match (kind, stroking) {
138        (ObjectKind::Path, false) => scheme.path_fill,
139        (ObjectKind::Path, true) => scheme.path_stroke,
140        (ObjectKind::Text, false) => scheme.text_fill,
141        (ObjectKind::Text, true) => scheme.text_stroke,
142        (ObjectKind::Other, _) => return c,
143    };
144    Argb {
145        a: c.a,
146        ..replacement
147    }
148}
149
150/// What a colour ref resolved to, in the three states the oracle
151/// distinguishes.
152///
153/// The distinction matters because two of them are white. A resolved colour
154/// occupies the **low 24 bits**, so a genuinely white fill is `0x00FFFFFF`;
155/// the "nothing resolved" value is `0xFFFFFFFF`, with the top byte set, and
156/// the invisibility test compares the whole 32-bit word.
157///
158/// So **a white fill is white, not invisible.** Collapsing the two costs
159/// every white object in the corpus: a transparency group painting a white
160/// square through a soft mask paints nothing.
161#[derive(Debug, Clone, Copy, PartialEq)]
162enum ColorRef {
163    /// A colour the space produced.
164    Resolved(Rgb),
165    /// `0xFFFFFFFF` — nothing resolved, and the object is invisible.
166    Invisible,
167    /// No colour of its own, so the enclosing state's is inherited.
168    Missing,
169}
170
171/// Resolve a colour value to one of the three [`ColorRef`] states.
172///
173/// A **pattern** never resolves through its colour space in the ordinary way:
174/// the pattern space's own answer wins when it has one, and otherwise one of
175/// two sentinels — mid grey for a **coloured tiling** pattern, which is
176/// visible, and the invisibility word for everything else. That path
177/// rarely reaches a pixel, because a pattern is drained out of the ordinary
178/// draw; the exception is a type-3 text object, whose `GetFillArgbForType3`
179/// runs before the pattern check.
180#[must_use]
181fn color_ref(value: &ColorValue) -> ColorRef {
182    if let Some(rgb) = value.to_rgb() {
183        return ColorRef::Resolved(rgb);
184    }
185    let Some(pattern) = value.pattern.as_ref() else {
186        return ColorRef::Missing;
187    };
188    let colored_tiling = matches!(
189        pattern.loaded.as_deref(),
190        Some(pdfrum_page::Pattern::Tiling(t)) if t.colored
191    );
192    if colored_tiling {
193        // `0x00BFBFBF`: mid grey, chosen precisely so it is *not* the
194        // invisibility word.
195        ColorRef::Resolved(Rgb {
196            r: 191.0 / 255.0,
197            g: 191.0 / 255.0,
198            b: 191.0 / 255.0,
199        })
200    } else {
201        ColorRef::Invisible
202    }
203}
204
205/// Resolve a page object's colour into the ARGB a device paints with.
206///
207/// `inherited` is the enclosing render state's colour, which a form `XObject`
208/// with no colour operators of its own picks up (`initial_states_`).
209/// `imposed` is a type-3 char proc's caller-imposed colour, which wins outright
210/// for an uncoloured glyph procedure.
211#[must_use]
212pub fn resolve_argb(
213    value: &ColorValue,
214    alpha: f32,
215    transfer: Option<&TransferFunc>,
216    inherited: Option<Argb>,
217    opts: &RenderOptions,
218    kind: ObjectKind,
219    stroking: bool,
220) -> Argb {
221    let base = match color_ref(value) {
222        ColorRef::Resolved(rgb) => {
223            let [r, g, b] = rgb.to_bytes();
224            Argb { a: 255, r, g, b }
225        }
226        ColorRef::Invisible => return Argb::TRANSPARENT,
227        // "MissingFillColor": no colour of its own, so inherit. With nothing
228        // to inherit the C++ reads a zeroed colour ref, which is black.
229        ColorRef::Missing => inherited.unwrap_or(Argb::BLACK),
230    };
231
232    let a = alpha_byte_truncating(alpha);
233    let transferred = match transfer {
234        Some(tf) if !tf.is_identity() => tf.translate(base),
235        _ => base,
236    };
237    translate_object_color(opts, transferred.with_alpha(a), kind, stroking)
238}
239
240#[cfg(test)]
241mod tests {
242    use std::sync::Arc;
243
244    use pdfrum_page::ColorSpace;
245    use smallvec::SmallVec;
246
247    use super::*;
248
249    fn gray(v: f32) -> ColorValue {
250        let mut c = ColorValue::default();
251        c.set_stock(ColorSpace::DeviceGray, &[v]);
252        c
253    }
254
255    #[test]
256    fn a_shading_pattern_colour_is_the_invisibility_sentinel() {
257        // A pattern is normally drained out of the draw, but a type-3 text
258        // object establishes its colour before the drain — and a shading
259        // pattern's colour ref is `0xFFFFFFFF`, which is transparent, not
260        // black. Reading it as "no colour, so inherit" paints solid glyphs.
261        let mut c = ColorValue::default();
262        c.set_space(Arc::new(ColorSpace::Pattern(Box::default())));
263        c.set_pattern(pdfrum_object::Name::from("P0"), &[], None);
264        let argb = resolve_argb(
265            &c,
266            1.0,
267            None,
268            None,
269            &RenderOptions::default(),
270            ObjectKind::Text,
271            false,
272        );
273        assert!(argb.is_invisible(), "got {argb:?}");
274    }
275
276    #[test]
277    fn a_pattern_colour_that_resolves_keeps_its_colour() {
278        // With a base space the operands resolve normally and no fallback
279        // applies at all.
280        let mut c = ColorValue::default();
281        c.set_space(Arc::new(ColorSpace::Pattern(Box::new(
282            pdfrum_page::PatternSpace {
283                base: Some(Box::new(ColorSpace::DeviceRgb)),
284            },
285        ))));
286        c.set_pattern(pdfrum_object::Name::from("P0"), &[1.0, 0.0, 0.0], None);
287        let argb = resolve_argb(
288            &c,
289            1.0,
290            None,
291            None,
292            &RenderOptions::default(),
293            ObjectKind::Text,
294            false,
295        );
296        assert_eq!(argb, Argb::opaque(255, 0, 0));
297    }
298
299    #[test]
300    fn a_white_fill_is_white_and_not_the_invisibility_sentinel() {
301        // `FXSYS_BGR` packs a resolved colour into the low 24 bits, so white
302        // is `0x00FFFFFF`; the invisibility test at cpdf_renderstatus.cpp:481
303        // compares the whole word against `0xFFFFFFFF`, which only
304        // `value_or(0xFFFFFFFF)` and the pattern fallback ever produce.
305        //
306        // Reading a resolved white as the sentinel makes every white object
307        // in the corpus paint nothing — a white square in a transparency
308        // group, a white-on-black `/BC` mask, a white page-covering fill.
309        let opts = RenderOptions::default();
310        let c = resolve_argb(&gray(1.0), 1.0, None, None, &opts, ObjectKind::Path, false);
311        assert_eq!(c, Argb::opaque(255, 255, 255));
312        assert!(!c.is_invisible());
313    }
314
315    #[test]
316    fn a_colour_that_will_not_resolve_inherits_rather_than_vanishing() {
317        // A `/Separation /None` produces no colour at all. With an enclosing
318        // colour it inherits; with none the C++ reads a zeroed colour ref,
319        // which is black — not transparent.
320        let none = ColorValue {
321            space: Some(Arc::new(ColorSpace::Separation(Box::new(
322                pdfrum_page::Separation {
323                    none: true,
324                    alternate: None,
325                    tint: None,
326                },
327            )))),
328            components: SmallVec::from_slice(&[1.0]),
329            pattern: None,
330        };
331        let opts = RenderOptions::default();
332        assert_eq!(
333            resolve_argb(&none, 1.0, None, None, &opts, ObjectKind::Path, false),
334            Argb::BLACK
335        );
336        assert_eq!(
337            resolve_argb(
338                &none,
339                1.0,
340                None,
341                Some(Argb::opaque(9, 8, 7)),
342                &opts,
343                ObjectKind::Path,
344                false
345            ),
346            Argb::opaque(9, 8, 7)
347        );
348    }
349
350    #[test]
351    fn alpha_truncates() {
352        // `(int32)(alpha * 255)`, with upstream's own `// not rounded.`
353        let opts = RenderOptions::default();
354        let c = resolve_argb(&gray(0.0), 0.5, None, None, &opts, ObjectKind::Path, false);
355        assert_eq!(c.a, 127);
356    }
357
358    #[test]
359    fn missing_color_inherits_from_the_enclosing_state() {
360        let opts = RenderOptions::default();
361        let empty = ColorValue {
362            space: None,
363            components: SmallVec::new(),
364            ..Default::default()
365        };
366        let inherited = Argb::opaque(10, 20, 30);
367        let c = resolve_argb(
368            &empty,
369            1.0,
370            None,
371            Some(inherited),
372            &opts,
373            ObjectKind::Path,
374            false,
375        );
376        assert_eq!((c.r, c.g, c.b), (10, 20, 30));
377    }
378
379    #[test]
380    fn gray_mode_uses_ntsc_weights() {
381        let opts = RenderOptions {
382            color_mode: ColorMode::Gray,
383            ..RenderOptions::default()
384        };
385        let mut red = ColorValue::default();
386        red.set_stock(ColorSpace::DeviceRgb, &[1.0, 0.0, 0.0]);
387        let c = resolve_argb(&red, 1.0, None, None, &opts, ObjectKind::Path, false);
388        // 255*30/100 = 76, not Rec.709's 54.
389        assert_eq!((c.r, c.g, c.b), (76, 76, 76));
390    }
391
392    #[test]
393    fn forced_scheme_leaves_images_alone() {
394        let scheme = crate::options::ColorScheme {
395            path_fill: Argb::opaque(1, 2, 3),
396            path_stroke: Argb::opaque(4, 5, 6),
397            text_fill: Argb::opaque(7, 8, 9),
398            text_stroke: Argb::opaque(10, 11, 12),
399        };
400        let opts = RenderOptions {
401            color_mode: ColorMode::Forced(scheme),
402            ..RenderOptions::default()
403        };
404        let value = {
405            let mut c = ColorValue::default();
406            c.set_space(Arc::new(ColorSpace::DeviceRgb));
407            let _ = c.set_components(&[0.0, 0.0, 0.0]);
408            c
409        };
410        let path = resolve_argb(&value, 1.0, None, None, &opts, ObjectKind::Path, false);
411        assert_eq!((path.r, path.g, path.b), (1, 2, 3));
412        let other = resolve_argb(&value, 1.0, None, None, &opts, ObjectKind::Other, false);
413        assert_eq!((other.r, other.g, other.b), (0, 0, 0));
414    }
415
416    #[test]
417    fn rgb_to_gray_is_the_oracle_formula() {
418        assert_eq!(rgb_to_gray(255, 255, 255), 255);
419        assert_eq!(rgb_to_gray(0, 0, 0), 0);
420        assert_eq!(rgb_to_gray(255, 0, 0), 76);
421        assert_eq!(rgb_to_gray(0, 255, 0), 150);
422        assert_eq!(rgb_to_gray(0, 0, 255), 28);
423    }
424}