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