Skip to main content

pdfrum_page/image/
mask.rs

1//! Image masking: stencils, colour keys, soft masks and `/Matte`
2//! (ISO 32000-1 §8.9.6).
3//!
4//! **`/SMask` wins over `/Mask` at every level.** When both are present, the
5//! colour-key array is not merely overridden — it is never read at all,
6//! because the code returns before reaching it.
7//!
8//! Two smaller rules are easy to get wrong:
9//!
10//! - A colour-key array shorter than two entries per component still turns
11//!   masking **on**; the C++ then reads uninitialized ranges. We default them
12//!   to `0/0`, so only exactly-zero samples become transparent.
13//! - `/Matte` needs **four** conditions at once — an array of exactly the
14//!   image's component count, a colour space that is not a pattern, and that
15//!   space needing no more components than the image has.
16//! - A mask that fails to load **does not fail its base image**: the mask is
17//!   dropped and the image is kept. A mask is also never resolution-reduced
18//!   and never carries a mask of its own.
19
20use crate::color::{ColorSpace, Rgb};
21use pdfrum_object::Array;
22
23/// A colour-key mask: per component, the closed range of raw sample values
24/// that is transparent.
25#[derive(Debug, Clone, PartialEq, Default)]
26pub struct ColorKey {
27    /// Per component, `(min, max)` inclusive.
28    pub ranges: Box<[(u32, u32)]>,
29}
30
31impl ColorKey {
32    /// Read a `/Mask` array.
33    ///
34    /// Returns a mask even when the array is too short, with the missing
35    /// ranges defaulting to `0..=0` — which makes only exactly-zero samples
36    /// transparent. The C++ leaves them uninitialized here; matching
37    /// *undefined* behaviour is not possible, and this is the closest
38    /// defensible reading.
39    #[must_use]
40    pub fn from_array(array: &Array, components: usize, max_data: u32) -> Self {
41        let complete = array.len() >= components * 2;
42        let ranges = (0..components)
43            .map(|i| {
44                if !complete {
45                    return (0u32, 0u32);
46                }
47                let lo = array.int_at(i * 2).unwrap_or(0).max(0);
48                let hi = array
49                    .int_at(i * 2 + 1)
50                    .unwrap_or(0)
51                    .clamp(0, i64::from(max_data));
52                (
53                    u32::try_from(lo).unwrap_or(0),
54                    u32::try_from(hi).unwrap_or(0),
55                )
56            })
57            .collect();
58        Self { ranges }
59    }
60
61    /// Whether the array stated a full set of ranges.
62    #[must_use]
63    pub fn is_complete(array: &Array, components: usize) -> bool {
64        array.len() >= components * 2
65    }
66
67    /// Whether a pixel is transparent: **every** component must fall inside
68    /// its range.
69    #[must_use]
70    pub fn is_transparent(&self, samples: &[u32]) -> bool {
71        if self.ranges.is_empty() {
72            return false;
73        }
74        self.ranges.iter().enumerate().all(|(i, (lo, hi))| {
75            let v = samples.get(i).copied().unwrap_or(0);
76            v >= *lo && v <= *hi
77        })
78    }
79}
80
81/// An image's alpha, however it was expressed.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum ImageMask {
85    /// A `/Mask` array naming transparent sample values.
86    ColorKey(ColorKey),
87    /// A separate grayscale image, `/SMask` or a `/Mask` stream, at its own
88    /// resolution — masks are **never** resolution-reduced, so a 400×400 mask
89    /// stays 400×400 even beside a 50×50 base image.
90    Alpha {
91        /// Mask width in samples.
92        width: u32,
93        /// Mask height in samples.
94        height: u32,
95        /// One byte per sample; 255 is opaque.
96        alpha: Box<[u8]>,
97        /// Whether the mask is a `/Mask` stencil rather than an `/SMask`,
98        /// which inverts its sense.
99        stencil: bool,
100    },
101}
102
103impl ImageMask {
104    /// The alpha at `(x, y)`, or 255 when the mask does not cover it.
105    #[must_use]
106    pub fn alpha_at(&self, x: u32, y: u32) -> u8 {
107        match self {
108            Self::ColorKey(_) => 255,
109            Self::Alpha {
110                width,
111                height,
112                alpha,
113                stencil,
114            } => {
115                if x >= *width || y >= *height {
116                    return 255;
117                }
118                let index = usize::try_from(y)
119                    .ok()
120                    .and_then(|row| row.checked_mul(usize::try_from(*width).ok()?))
121                    .and_then(|base| base.checked_add(usize::try_from(x).ok()?));
122                let v = index.and_then(|i| alpha.get(i)).copied().unwrap_or(255);
123                if *stencil { 255 - v } else { v }
124            }
125        }
126    }
127}
128
129/// The `/Matte` colour a pre-blended soft-masked image was composed against.
130///
131/// All four preconditions must hold at once; any one failing means no matte,
132/// which the compositor treats as "not pre-blended".
133#[must_use]
134pub fn matte_color(
135    matte: Option<&Array>,
136    space: Option<&ColorSpace>,
137    components: usize,
138) -> Option<Rgb> {
139    let matte = matte?;
140    let space = space?;
141    if matches!(space, ColorSpace::Pattern(_)) {
142        return None;
143    }
144    // Exactly the image's component count, not merely enough.
145    if matte.len() != components {
146        return None;
147    }
148    if space.n_components() > components {
149        return None;
150    }
151    let comps: Vec<f32> = (0..components)
152        .map(|i| matte.number_at_or_zero(i))
153        .collect();
154    Some(space.to_rgb(&comps))
155}
156
157#[cfg(test)]
158mod tests {
159    // Test fixtures quote the oracle's own vectors, compare floats exactly
160    // where the behaviour being pinned is exact, and index arrays whose
161    // length the fixture itself fixes.
162    #![allow(
163        clippy::unreadable_literal,
164        clippy::float_cmp,
165        clippy::indexing_slicing,
166        clippy::cast_precision_loss,
167        clippy::cast_possible_truncation,
168        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
169    )]
170
171    use super::{ColorKey, ImageMask, matte_color};
172    use crate::color::ColorSpace;
173    use pdfrum_object::{Array, Object};
174
175    fn array(values: &[i64]) -> Array {
176        Array::of(values.iter().copied().map(Object::Int))
177    }
178
179    #[test]
180    fn a_colour_key_needs_every_component_inside_its_range() {
181        let key = ColorKey::from_array(&array(&[10, 20, 30, 40, 50, 60]), 3, 255);
182        assert!(key.is_transparent(&[15, 35, 55]));
183        // One component outside is enough to make it opaque.
184        assert!(!key.is_transparent(&[15, 35, 61]));
185        assert!(!key.is_transparent(&[9, 35, 55]));
186        // The bounds are inclusive.
187        assert!(key.is_transparent(&[10, 30, 50]));
188        assert!(key.is_transparent(&[20, 40, 60]));
189    }
190
191    #[test]
192    fn a_short_array_still_masks_but_only_exactly_zero_samples() {
193        let short = array(&[10, 20]);
194        assert!(!ColorKey::is_complete(&short, 3));
195        let key = ColorKey::from_array(&short, 3, 255);
196        assert!(key.is_transparent(&[0, 0, 0]));
197        assert!(!key.is_transparent(&[15, 0, 0]));
198    }
199
200    #[test]
201    fn colour_key_bounds_are_clamped_to_the_sample_range() {
202        let key = ColorKey::from_array(&array(&[-5, 999]), 1, 255);
203        assert_eq!(key.ranges.first(), Some(&(0, 255)));
204    }
205
206    #[test]
207    fn an_alpha_mask_reads_out_of_bounds_as_opaque() {
208        let mask = ImageMask::Alpha {
209            width: 2,
210            height: 2,
211            alpha: Box::from(&[0u8, 64, 128, 255][..]),
212            stencil: false,
213        };
214        assert_eq!(mask.alpha_at(0, 0), 0);
215        assert_eq!(mask.alpha_at(1, 1), 255);
216        assert_eq!(mask.alpha_at(5, 5), 255);
217    }
218
219    #[test]
220    fn a_stencil_mask_inverts_its_sense() {
221        let mask = ImageMask::Alpha {
222            width: 1,
223            height: 1,
224            alpha: Box::from(&[0u8][..]),
225            stencil: true,
226        };
227        assert_eq!(mask.alpha_at(0, 0), 255);
228    }
229
230    #[test]
231    fn matte_needs_all_four_preconditions() {
232        let rgb = ColorSpace::DeviceRgb;
233        let three = array(&[0, 0, 0]);
234        assert!(matte_color(Some(&three), Some(&rgb), 3).is_some());
235        // Wrong length, exactly rather than merely enough.
236        assert!(matte_color(Some(&array(&[0, 0])), Some(&rgb), 3).is_none());
237        assert!(matte_color(Some(&array(&[0, 0, 0, 0])), Some(&rgb), 3).is_none());
238        // No space at all.
239        assert!(matte_color(Some(&three), None, 3).is_none());
240        // A pattern space.
241        let pattern = ColorSpace::Pattern(Box::default());
242        assert!(matte_color(Some(&three), Some(&pattern), 3).is_none());
243        // A space wanting more components than the image has.
244        assert!(matte_color(Some(&array(&[0])), Some(&rgb), 1).is_none());
245        // No array.
246        assert!(matte_color(None, Some(&rgb), 3).is_none());
247    }
248}