Skip to main content

pdfrum_page/state/
general.rs

1//! Alphas, blend mode, soft mask, and the `/ExtGState` keys that are parsed
2//! but never consumed (ISO 32000-1 §11.6.6).
3//!
4//! Eleven `/ExtGState` keys affect output; **twelve more are parsed and
5//! stored and then never read by anything**. They are kept as fields anyway,
6//! because a structure dump reports them and a future overprint
7//! implementation will want them — but nothing here may let them change
8//! rendering, which is the one rule this module enforces.
9
10use crate::transparency::SoftMask;
11use std::sync::Arc;
12
13/// A separable blend mode (ISO 32000-1 table 136).
14///
15/// An unrecognised `/BM` name is **`Normal`**, not an error.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub enum BlendMode {
18    /// Paint the source over the backdrop.
19    #[default]
20    Normal,
21    /// A synonym for [`Self::Normal`] kept for round-tripping.
22    Compatible,
23    /// Multiply the two.
24    Multiply,
25    /// Multiply the complements.
26    Screen,
27    /// Multiply or screen depending on the backdrop.
28    Overlay,
29    /// Take the darker.
30    Darken,
31    /// Take the lighter.
32    Lighten,
33    /// Brighten the backdrop to reflect the source.
34    ColorDodge,
35    /// Darken the backdrop to reflect the source.
36    ColorBurn,
37    /// Multiply or screen depending on the source.
38    HardLight,
39    /// Darken or lighten depending on the source.
40    SoftLight,
41    /// The absolute difference.
42    Difference,
43    /// A lower-contrast difference.
44    Exclusion,
45    /// The source's hue with the backdrop's saturation and luminosity.
46    Hue,
47    /// The source's saturation.
48    Saturation,
49    /// The source's hue and saturation.
50    Color,
51    /// The source's luminosity.
52    Luminosity,
53}
54
55impl BlendMode {
56    /// The mode a `/BM` name denotes, defaulting to [`Self::Normal`].
57    #[must_use]
58    pub fn from_name(name: &[u8]) -> Self {
59        match name {
60            b"Multiply" => Self::Multiply,
61            b"Screen" => Self::Screen,
62            b"Overlay" => Self::Overlay,
63            b"Darken" => Self::Darken,
64            b"Lighten" => Self::Lighten,
65            b"ColorDodge" => Self::ColorDodge,
66            b"ColorBurn" => Self::ColorBurn,
67            b"HardLight" => Self::HardLight,
68            b"SoftLight" => Self::SoftLight,
69            b"Difference" => Self::Difference,
70            b"Exclusion" => Self::Exclusion,
71            b"Hue" => Self::Hue,
72            b"Saturation" => Self::Saturation,
73            b"Color" => Self::Color,
74            b"Luminosity" => Self::Luminosity,
75            b"Compatible" => Self::Compatible,
76            // Every unrecognised name, including `/Normal` itself.
77            _ => Self::Normal,
78        }
79    }
80
81    /// Whether compositing in this mode needs the backdrop.
82    ///
83    /// PDFium marks a holder as needing background alpha for any mode past
84    /// `Multiply`, which is what this reproduces.
85    #[must_use]
86    pub fn needs_backdrop(self) -> bool {
87        !matches!(self, Self::Normal | Self::Compatible | Self::Multiply)
88    }
89}
90
91/// The rendering intent an `/RI` names.
92///
93/// Stored as PDFium stores it — an integer tag — and **never consumed by
94/// rendering**.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub enum RenderIntent {
97    /// Anything that is not one of the three recognised names.
98    #[default]
99    Unknown,
100    /// `/AbsoluteColorimetric`.
101    Absolute,
102    /// `/Saturation`.
103    Saturation,
104    /// `/Perceptual`.
105    Perceptual,
106}
107
108impl RenderIntent {
109    /// The intent a name denotes, matching on its **first four bytes** as
110    /// PDFium does — so `/Percept` and `/Perceptual` are the same intent.
111    #[must_use]
112    pub fn from_name(name: &[u8]) -> Self {
113        match name.get(..4) {
114            Some(b"Abso") => Self::Absolute,
115            Some(b"Satu") => Self::Saturation,
116            Some(b"Perc") => Self::Perceptual,
117            _ => Self::Unknown,
118        }
119    }
120}
121
122/// The `/ExtGState` parameters that reach compositing, plus the ones that do
123/// not.
124///
125/// The several booleans are not a state machine to be folded into an enum:
126/// each is an independent `/ExtGState` key that a file sets on its own, and
127/// five of them are inert flags kept only so a dump can report them.
128#[expect(
129    clippy::struct_excessive_bools,
130    reason = "each flag is an independent /ExtGState key, not a mode"
131)]
132#[derive(Debug, Clone, PartialEq)]
133pub struct GeneralState {
134    /// `/ca`, clamped to `0..=1`.
135    pub fill_alpha: f32,
136    /// `/CA`, clamped to `0..=1`.
137    pub stroke_alpha: f32,
138    /// `/BM`.
139    pub blend: BlendMode,
140    /// `/SMask`, shared because a state stack clones cheaply.
141    pub soft_mask: Option<Arc<SoftMask>>,
142    /// The transfer function `/TR` or `/TR2` names, sampled to three
143    /// 256-entry tables.
144    pub transfer: Option<Arc<crate::transfer::TransferFunc>>,
145
146    // ---- Parsed and stored; nothing reads these ----
147    /// `/RI`.
148    pub render_intent: RenderIntent,
149    /// `/OP`, the stroking overprint flag.
150    pub stroke_overprint: bool,
151    /// `/op`, the non-stroking one.
152    pub fill_overprint: bool,
153    /// `/OPM`.
154    pub overprint_mode: i64,
155    /// `/FL`, also settable by the `i` operator.
156    pub flatness: f32,
157    /// `/SM`.
158    pub smoothness: f32,
159    /// `/SA`.
160    pub stroke_adjust: bool,
161    /// `/AIS`.
162    pub alpha_is_shape: bool,
163    /// `/TK`.
164    pub text_knockout: bool,
165}
166
167impl Default for GeneralState {
168    fn default() -> Self {
169        Self {
170            fill_alpha: 1.0,
171            stroke_alpha: 1.0,
172            blend: BlendMode::Normal,
173            soft_mask: None,
174            transfer: None,
175            render_intent: RenderIntent::Unknown,
176            stroke_overprint: false,
177            fill_overprint: false,
178            overprint_mode: 0,
179            flatness: 0.0,
180            smoothness: 0.0,
181            stroke_adjust: false,
182            alpha_is_shape: false,
183            text_knockout: false,
184        }
185    }
186}
187
188impl GeneralState {
189    /// Reset the four parameters entering a transparency group clears.
190    ///
191    /// This is the group-isolation rule: a group starts compositing from a
192    /// clean slate, so its contents cannot see the enclosing blend mode,
193    /// alphas or soft mask (ISO 32000-1 §11.6.6).
194    pub fn enter_transparency_group(&mut self) {
195        self.blend = BlendMode::Normal;
196        self.stroke_alpha = 1.0;
197        self.fill_alpha = 1.0;
198        self.soft_mask = None;
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    // Test fixtures quote the oracle's own vectors, compare floats exactly
205    // where the behaviour being pinned is exact, and index arrays whose
206    // length the fixture itself fixes.
207    #![allow(
208        clippy::unreadable_literal,
209        clippy::float_cmp,
210        clippy::indexing_slicing,
211        clippy::cast_precision_loss,
212        clippy::cast_possible_truncation,
213        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
214    )]
215
216    use super::{BlendMode, GeneralState, RenderIntent};
217
218    #[test]
219    fn an_unknown_blend_name_is_normal() {
220        assert_eq!(BlendMode::from_name(b"Multiply"), BlendMode::Multiply);
221        assert_eq!(BlendMode::from_name(b"Luminosity"), BlendMode::Luminosity);
222        assert_eq!(BlendMode::from_name(b"Normal"), BlendMode::Normal);
223        assert_eq!(BlendMode::from_name(b"NotAMode"), BlendMode::Normal);
224        assert_eq!(BlendMode::from_name(b""), BlendMode::Normal);
225        // Case sensitive.
226        assert_eq!(BlendMode::from_name(b"multiply"), BlendMode::Normal);
227    }
228
229    #[test]
230    fn backdrop_is_needed_past_multiply() {
231        assert!(!BlendMode::Normal.needs_backdrop());
232        assert!(!BlendMode::Compatible.needs_backdrop());
233        assert!(!BlendMode::Multiply.needs_backdrop());
234        assert!(BlendMode::Screen.needs_backdrop());
235        assert!(BlendMode::Luminosity.needs_backdrop());
236    }
237
238    #[test]
239    fn render_intents_match_on_four_bytes() {
240        assert_eq!(
241            RenderIntent::from_name(b"AbsoluteColorimetric"),
242            RenderIntent::Absolute
243        );
244        assert_eq!(RenderIntent::from_name(b"Abso"), RenderIntent::Absolute);
245        assert_eq!(
246            RenderIntent::from_name(b"Perceptual"),
247            RenderIntent::Perceptual
248        );
249        assert_eq!(RenderIntent::from_name(b"Rel"), RenderIntent::Unknown);
250        assert_eq!(RenderIntent::from_name(b""), RenderIntent::Unknown);
251    }
252
253    #[test]
254    fn entering_a_group_clears_exactly_four_parameters() {
255        let mut s = GeneralState {
256            fill_alpha: 0.5,
257            stroke_alpha: 0.25,
258            blend: BlendMode::Multiply,
259            overprint_mode: 7,
260            flatness: 3.0,
261            ..GeneralState::default()
262        };
263        s.enter_transparency_group();
264        assert!((s.fill_alpha - 1.0).abs() < 1e-6);
265        assert!((s.stroke_alpha - 1.0).abs() < 1e-6);
266        assert_eq!(s.blend, BlendMode::Normal);
267        assert!(s.soft_mask.is_none());
268        // The inert fields are untouched.
269        assert_eq!(s.overprint_mode, 7);
270        assert!((s.flatness - 3.0).abs() < 1e-6);
271    }
272
273    #[test]
274    fn the_defaults_are_fully_opaque_and_unblended() {
275        let s = GeneralState::default();
276        assert!((s.fill_alpha - 1.0).abs() < 1e-6);
277        assert!((s.stroke_alpha - 1.0).abs() < 1e-6);
278        assert_eq!(s.blend, BlendMode::Normal);
279        assert!(s.soft_mask.is_none());
280        assert!(s.transfer.is_none());
281    }
282}