Skip to main content

pdfrum_page/
transparency.rs

1//! Transparency groups and soft masks (ISO 32000-1 §11.4, §11.6.5).
2//!
3//! # `/S` defaults to luminosity
4//!
5//! A soft mask's `/S` selects between alpha and luminosity sources, and the
6//! test is **`value != "Alpha"`** — so a missing `/S`, a misspelled one, and
7//! `/Luminosity` itself all mean luminosity. Only the exact string `Alpha`
8//! selects the other.
9//!
10//! # `/K` is parsed and not honoured
11//!
12//! Knockout groups are **unimplemented in PDFium**: `/K` is never read
13//! anywhere in its core, whose transparency model carries only "is a group"
14//! and "is isolated". We parse `/K` into the model because it is genuinely in
15//! the file, and the renderer ignores it so output matches the oracle.
16//!
17//! # Pages are always isolated
18//!
19//! A page's constructor sets isolation unconditionally, whatever its
20//! `/Group` says — so the page-level group is always isolated even when the
21//! dictionary declares otherwise.
22
23use crate::color::ColorSpace;
24use crate::function::FunctionCache;
25use crate::names;
26use crate::transfer::CHANNEL_SAMPLES;
27use kurbo::Affine;
28use pdfrum_common::{Diagnostics, Limits};
29use pdfrum_object::{Dict, Object, Resolve, Stream};
30
31/// A transparency group's attributes.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct Transparency {
34    /// Whether the content forms a transparency group at all.
35    pub group: bool,
36    /// Whether the group composites against a blank backdrop rather than the
37    /// page's.
38    pub isolated: bool,
39    /// Whether the group is a knockout group. Parsed, never honoured.
40    pub knockout: bool,
41}
42
43impl Transparency {
44    /// Read a `/Group` dictionary.
45    ///
46    /// The dictionary must have `/S` exactly `Transparency`; anything else
47    /// means no group at all. `/I` and `/K` are true for **any non-zero
48    /// value**, integers included, which is how a `/I 1` works alongside a
49    /// `/I true`.
50    #[must_use]
51    pub fn from_group(group: Option<&Dict>, r: &impl Resolve) -> Self {
52        let Some(group) = group else {
53            return Self::default();
54        };
55        if group.byte_string(names::S, r).as_deref() != Some(b"Transparency") {
56            return Self::default();
57        }
58        Self {
59            group: true,
60            isolated: truthy(group, names::I, r),
61            knockout: truthy(group, names::K, r),
62        }
63    }
64
65    /// A page's group, which is isolated whatever the dictionary says.
66    #[must_use]
67    pub fn for_page(group: Option<&Dict>, r: &impl Resolve) -> Self {
68        Self {
69            isolated: true,
70            ..Self::from_group(group, r)
71        }
72    }
73}
74
75/// Whether a key holds anything non-zero: a `true`, or any non-zero number.
76fn truthy(dict: &Dict, key: &pdfrum_object::Name, r: &impl Resolve) -> bool {
77    match dict.get(key, r).as_deref() {
78        Some(Object::Bool(b)) => *b,
79        Some(Object::Int(i)) => *i != 0,
80        Some(Object::Real(v)) => *v != 0.0,
81        _ => false,
82    }
83}
84
85/// Which channel of the group a soft mask reads.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum SoftMaskKind {
88    /// The group's rendered luminosity. **The default**, reached by every
89    /// `/S` that is not exactly `Alpha`.
90    #[default]
91    Luminosity,
92    /// The group's alpha.
93    Alpha,
94}
95
96/// A soft mask from an `/ExtGState`'s `/SMask`.
97#[derive(Debug, Clone, PartialEq)]
98pub struct SoftMask {
99    /// The group whose rendering becomes the mask.
100    pub group: Stream,
101    /// Which channel to read.
102    pub kind: SoftMaskKind,
103    /// The backdrop the group composites against, in luminosity mode.
104    /// **Opaque black** by default.
105    pub backdrop: crate::color::Rgb,
106    /// The transfer applied to each mask value, when `/TR` supplied one.
107    pub transfer: Option<Box<[u8; CHANNEL_SAMPLES]>>,
108    /// The transformation in force when the `/ExtGState` was applied, which
109    /// is what places the mask.
110    pub matrix: Affine,
111    /// The group's own page objects, interpreted from `group`.
112    ///
113    /// A soft mask's `/G` is a form `XObject` like any other, and what it paints
114    /// is what the mask *is*: a luminosity mask reads the group's rendered
115    /// luminance, an alpha mask its coverage. Left empty the mask is the `/BC`
116    /// backdrop alone — exact for a backdrop-only mask and wrong for every
117    /// other, which is how it silently blanked or revealed whole objects.
118    ///
119    /// Filled in by the interpreter, which is the only layer with a resolver;
120    /// `load` leaves it empty.
121    pub objects: Vec<crate::page::PageObject>,
122}
123
124impl SoftMask {
125    /// Read a `/SMask` dictionary.
126    ///
127    /// Returns `None` — meaning no mask — when the value is not a dictionary
128    /// at all, which is how `/SMask /None` reaches the correct answer, and
129    /// when `/G` is not a stream.
130    #[must_use]
131    pub fn load<R: Resolve>(
132        value: &Object,
133        matrix: Affine,
134        r: &R,
135        functions: &mut FunctionCache,
136        limits: &Limits,
137        diags: &mut Diagnostics,
138    ) -> Option<Self> {
139        // A name — including `/None` — is not a dictionary, which is exactly
140        // the spec's semantics reached by accident.
141        let resolved = value.resolve(r).ok()?;
142        let dict = resolved.as_dict()?;
143        // `/G` **must** be a stream or the whole mask is dropped.
144        let group = dict.stream(names::G, r)?;
145
146        // Only the exact string `Alpha` selects alpha; everything else,
147        // absent keys included, is luminosity.
148        let kind = if dict.byte_string(names::S, r).as_deref() == Some(b"Alpha") {
149            SoftMaskKind::Alpha
150        } else {
151            SoftMaskKind::Luminosity
152        };
153
154        let backdrop = match kind {
155            // `/BC` is consulted only in luminosity mode.
156            SoftMaskKind::Luminosity => backdrop_color(dict, &group, r, functions, limits, diags),
157            SoftMaskKind::Alpha => crate::color::Rgb::BLACK,
158        };
159
160        // `/TR` is accepted only as a dictionary or a stream, so
161        // `/TR /Identity` is correctly ignored.
162        let transfer = dict
163            .raw(names::TR)
164            .filter(|obj| {
165                matches!(
166                    obj.resolve(r).as_deref(),
167                    Ok(Object::Dict(_) | Object::Stream(_))
168                )
169            })
170            .and_then(|obj| functions.load(obj, r, limits, diags))
171            .map(|func| {
172                let mut lut = Box::new([0u8; CHANNEL_SAMPLES]);
173                let mut results = vec![0.0f32; func.output_count().max(1)];
174                for (i, slot) in lut.iter_mut().enumerate() {
175                    #[expect(
176                        clippy::cast_precision_loss,
177                        reason = "an index below 256 is exact in f32"
178                    )]
179                    let input = (i as f32) / 255.0;
180                    let identity = u8::try_from(i).unwrap_or(u8::MAX);
181                    if func.eval_into(&[input], &mut results) == 0 {
182                        *slot = identity;
183                        continue;
184                    }
185                    // Only output component 0 is used.
186                    #[expect(
187                        clippy::cast_possible_truncation,
188                        clippy::cast_sign_loss,
189                        reason = "the clamp bounds the product to 0..=255"
190                    )]
191                    let byte = (results.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0)
192                        .round() as u8;
193                    *slot = byte;
194                }
195                lut
196            });
197
198        Some(Self {
199            group,
200            kind,
201            backdrop,
202            transfer,
203            matrix,
204            // Filled in by the interpreter, which has the resolver and the
205            // recursion guards a form parse needs.
206            objects: Vec::new(),
207        })
208    }
209
210    /// The mask value for one luminosity or alpha byte.
211    #[must_use]
212    pub fn apply_transfer(&self, value: u8) -> u8 {
213        self.transfer
214            .as_ref()
215            .and_then(|lut| lut.get(usize::from(value)))
216            .copied()
217            .unwrap_or(value)
218    }
219}
220
221/// The `/BC` backdrop colour, defaulting to **opaque black**.
222///
223/// The colour space is the `/G` stream's own `/Group` `/CS`, not the soft
224/// mask dictionary's — a distinction that matters because the two are
225/// different dictionaries. An unsupported group space (`Lab`, any special
226/// family, or a non-normal `ICCBased`) falls back to black.
227fn backdrop_color<R: Resolve>(
228    smask: &Dict,
229    group_stream: &Stream,
230    r: &R,
231    functions: &mut FunctionCache,
232    limits: &Limits,
233    diags: &mut Diagnostics,
234) -> crate::color::Rgb {
235    let default = crate::color::Rgb::BLACK;
236    let Some(bc) = smask.array(names::BC, r) else {
237        return default;
238    };
239    let Some(cs_obj) = group_stream
240        .dict
241        .dict(names::GROUP, r)
242        .and_then(|g| g.raw(names::CS).cloned())
243    else {
244        return default;
245    };
246    let Some(space) = crate::color::load_colorspace(&cs_obj, None, r, functions, limits, diags)
247    else {
248        return default;
249    };
250    if matches!(space, ColorSpace::Lab(_)) || space.is_special() {
251        return default;
252    }
253    if matches!(space, ColorSpace::IccBased(_)) && !space.is_normal() {
254        return default;
255    }
256    // At most eight values are read, and the buffer is padded to at least
257    // eight — so a nine-component `DeviceN` reads only its first eight.
258    let count = bc.len().min(8);
259    let width = space.n_components().max(8);
260    let mut comps = vec![0.0f32; width];
261    for i in 0..count {
262        if let Some(slot) = comps.get_mut(i) {
263            *slot = bc.number_at_or_zero(i);
264        }
265    }
266    space.to_rgb(&comps)
267}
268
269#[cfg(test)]
270mod tests {
271    // Test fixtures quote the oracle's own vectors, compare floats exactly
272    // where the behaviour being pinned is exact, and index arrays whose
273    // length the fixture itself fixes.
274    #![allow(
275        clippy::unreadable_literal,
276        clippy::float_cmp,
277        clippy::indexing_slicing,
278        clippy::cast_precision_loss,
279        clippy::cast_possible_truncation,
280        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
281    )]
282
283    use super::{SoftMask, SoftMaskKind, Transparency};
284    use crate::function::FunctionCache;
285    use kurbo::Affine;
286    use pdfrum_common::{Diagnostics, Limits};
287    use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, ObjRef, Object, Resolve, Stream};
288    use std::sync::Arc;
289
290    /// The reference a test dictionary's `/G` points at.
291    const GROUP_REF: ObjRef = ObjRef::new(1, 0);
292
293    /// A store holding one group stream, since a dictionary value may never
294    /// be a direct stream (ISO 32000-1 §7.3.8.1).
295    struct GroupStore;
296
297    impl Resolve for GroupStore {
298        fn fetch(&self, r: ObjRef) -> Result<Arc<Object>, pdfrum_object::Error> {
299            if r.num == GROUP_REF.num {
300                return Ok(Arc::new(Object::Stream(Box::new(Stream::new(
301                    Dict::new(),
302                    ByteSpan::empty(),
303                )))));
304            }
305            Err(pdfrum_object::Error::UnresolvedRef(r))
306        }
307    }
308
309    fn group_ref() -> Object {
310        Object::Ref(GROUP_REF)
311    }
312
313    fn load_mask(pairs: Vec<(Name, Object)>) -> Option<SoftMask> {
314        let mut funcs = FunctionCache::new();
315        let mut diags = Diagnostics::default();
316        SoftMask::load(
317            &Object::Dict(Dict::from_pairs(pairs)),
318            Affine::IDENTITY,
319            &GroupStore,
320            &mut funcs,
321            &Limits::default(),
322            &mut diags,
323        )
324    }
325
326    #[test]
327    fn a_group_needs_s_to_be_exactly_transparency() {
328        let good = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transparency")))]);
329        assert!(Transparency::from_group(Some(&good), &NoResolve).group);
330
331        let wrong = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transp")))]);
332        assert!(!Transparency::from_group(Some(&wrong), &NoResolve).group);
333        assert!(!Transparency::from_group(None, &NoResolve).group);
334    }
335
336    #[test]
337    fn isolation_and_knockout_are_true_for_any_non_zero_value() {
338        let make = |key: &str, value: Object| {
339            Dict::from_pairs([
340                (Name::from("S"), Object::Name(Name::from("Transparency"))),
341                (Name::from(key), value),
342            ])
343        };
344        assert!(
345            Transparency::from_group(Some(&make("I", Object::Bool(true))), &NoResolve).isolated
346        );
347        assert!(Transparency::from_group(Some(&make("I", Object::Int(1))), &NoResolve).isolated);
348        assert!(!Transparency::from_group(Some(&make("I", Object::Int(0))), &NoResolve).isolated);
349        assert!(
350            Transparency::from_group(Some(&make("K", Object::Bool(true))), &NoResolve).knockout
351        );
352    }
353
354    #[test]
355    fn a_page_is_isolated_whatever_its_group_says() {
356        let not_isolated = Dict::from_pairs([
357            (Name::from("S"), Object::Name(Name::from("Transparency"))),
358            (Name::from("I"), Object::Bool(false)),
359        ]);
360        assert!(Transparency::for_page(Some(&not_isolated), &NoResolve).isolated);
361        // Even with no group at all.
362        assert!(Transparency::for_page(None, &NoResolve).isolated);
363    }
364
365    #[test]
366    fn a_soft_mask_defaults_to_luminosity() {
367        let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
368        assert_eq!(mask.kind, SoftMaskKind::Luminosity);
369
370        // `/Luminosity` is the same answer.
371        let mask = load_mask(vec![
372            (Name::from("G"), group_ref()),
373            (Name::from("S"), Object::Name(Name::from("Luminosity"))),
374        ])
375        .expect("should load");
376        assert_eq!(mask.kind, SoftMaskKind::Luminosity);
377
378        // …and so is garbage.
379        let mask = load_mask(vec![
380            (Name::from("G"), group_ref()),
381            (Name::from("S"), Object::Name(Name::from("Nonsense"))),
382        ])
383        .expect("should load");
384        assert_eq!(mask.kind, SoftMaskKind::Luminosity);
385    }
386
387    #[test]
388    fn only_the_exact_string_alpha_selects_alpha() {
389        let mask = load_mask(vec![
390            (Name::from("G"), group_ref()),
391            (Name::from("S"), Object::Name(Name::from("Alpha"))),
392        ])
393        .expect("should load");
394        assert_eq!(mask.kind, SoftMaskKind::Alpha);
395    }
396
397    #[test]
398    fn a_mask_without_a_stream_g_is_dropped() {
399        assert!(load_mask(vec![(Name::from("G"), Object::Int(7))]).is_none());
400        assert!(load_mask(vec![]).is_none());
401    }
402
403    #[test]
404    fn smask_none_yields_no_mask() {
405        let mut funcs = FunctionCache::new();
406        let mut diags = Diagnostics::default();
407        let got = SoftMask::load(
408            &Object::Name(Name::from("None")),
409            Affine::IDENTITY,
410            &NoResolve,
411            &mut funcs,
412            &Limits::default(),
413            &mut diags,
414        );
415        assert!(got.is_none());
416    }
417
418    #[test]
419    fn the_default_backdrop_is_opaque_black() {
420        let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
421        assert_eq!(mask.backdrop, crate::color::Rgb::BLACK);
422    }
423
424    #[test]
425    fn a_tr_name_is_ignored_rather_than_installed() {
426        let mask = load_mask(vec![
427            (Name::from("G"), group_ref()),
428            (Name::from("TR"), Object::Name(Name::from("Identity"))),
429        ])
430        .expect("should load");
431        assert!(mask.transfer.is_none());
432        // With no transfer the value passes through.
433        assert_eq!(mask.apply_transfer(128), 128);
434    }
435}