Skip to main content

pdfrum_page/state/
mod.rs

1//! The graphics state and its stack (ISO 32000-1 ยง8.4).
2//!
3//! One record with the heavy members behind `Arc`, so `q` is a cheap clone
4//! and `Q` is a pop. PDFium spreads the same data over five copy-on-write
5//! classes; the sharing is the point, not the class structure, so the `Arc`s
6//! reproduce it without the ceremony.
7//!
8//! `Q` on an empty stack is a **no-op**, not an error โ€” an unbalanced content
9//! stream simply keeps its current state.
10
11mod clip;
12mod extgstate;
13mod general;
14mod graph;
15mod marks;
16mod text;
17
18pub use clip::{ClipEntry, ClipRule, ClipStack, MAX_TEXT_OBJECTS, TextClipLimit, TextClipRun};
19pub use extgstate::apply_ext_gstate;
20pub(crate) use general::RenderIntent;
21pub use general::{BlendMode, GeneralState};
22pub use graph::StrokeParams;
23pub use marks::{ContentMarks, Mark};
24pub use text::TextState;
25pub(crate) use text::{TextCursor, glyph_matrix, kerning_shift};
26
27use crate::color::ColorValue;
28use kurbo::Affine;
29
30/// Everything `q` saves and `Q` restores.
31///
32/// Cloning is cheap: the clip stack's paths and the soft mask are shared, and
33/// the rest is a handful of scalars.
34#[derive(Debug, Clone, PartialEq)]
35pub struct GraphicsState {
36    /// The current transformation matrix.
37    pub ctm: Affine,
38    /// The non-stroking colour.
39    pub fill: ColorValue,
40    /// The stroking colour.
41    pub stroke: ColorValue,
42    /// How paths are stroked.
43    pub stroke_params: StrokeParams,
44    /// The clipping path.
45    pub clip: ClipStack,
46    /// Alphas, blend mode, soft mask and the inert `/ExtGState` keys.
47    pub general: GeneralState,
48    /// The text-showing parameters.
49    pub text: TextState,
50}
51
52impl Default for GraphicsState {
53    /// A page's initial state: identity transform, **opaque black fill and
54    /// stroke in `DeviceGray`**, no clip, fully opaque.
55    fn default() -> Self {
56        Self {
57            ctm: Affine::IDENTITY,
58            fill: ColorValue::default(),
59            stroke: ColorValue::default(),
60            stroke_params: StrokeParams::default(),
61            clip: ClipStack::new(),
62            general: GeneralState::default(),
63            text: TextState::default(),
64        }
65    }
66}
67
68/// The `q`/`Q` stack.
69///
70/// Unbounded, matching PDFium โ€” a content stream may nest as deeply as it
71/// likes, and the memory cost is bounded by the stream's own length.
72#[derive(Debug, Clone, PartialEq, Default)]
73pub struct StateStack {
74    saved: Vec<GraphicsState>,
75}
76
77impl StateStack {
78    /// An empty stack.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// How deep the stack is.
85    #[must_use]
86    pub fn depth(&self) -> usize {
87        self.saved.len()
88    }
89
90    /// Whether nothing has been saved.
91    #[must_use]
92    pub fn is_empty(&self) -> bool {
93        self.saved.is_empty()
94    }
95
96    /// `q`: save a copy of `state`.
97    pub fn push(&mut self, state: &GraphicsState) {
98        self.saved.push(state.clone());
99    }
100
101    /// `Q`: restore into `state`.
102    ///
103    /// Answers whether anything was restored โ€” a question, not a failed
104    /// mutation. An empty stack is a **no-op**, which is what keeps an
105    /// unbalanced stream rendering.
106    pub fn pop(&mut self, state: &mut GraphicsState) -> bool {
107        match self.saved.pop() {
108            Some(saved) => {
109                *state = saved;
110                true
111            }
112            None => false,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    // Test fixtures quote the oracle's own vectors, compare floats exactly
120    // where the behaviour being pinned is exact, and index arrays whose
121    // length the fixture itself fixes.
122    #![allow(
123        clippy::unreadable_literal,
124        clippy::float_cmp,
125        clippy::indexing_slicing,
126        clippy::cast_precision_loss,
127        clippy::cast_possible_truncation,
128        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
129    )]
130
131    use super::{GraphicsState, StateStack};
132    use crate::color::ColorSpace;
133    use crate::ops::LineCap;
134    use kurbo::Affine;
135
136    #[test]
137    fn the_initial_state_is_opaque_black_and_untransformed() {
138        let s = GraphicsState::default();
139        assert_eq!(s.ctm, Affine::IDENTITY);
140        assert_eq!(&s.fill.components[..], &[0.0]);
141        assert_eq!(&s.stroke.components[..], &[0.0]);
142        assert!((s.general.fill_alpha - 1.0).abs() < 1e-6);
143        assert!(s.clip.is_empty());
144        assert!((s.stroke_params.width - 1.0).abs() < 1e-6);
145    }
146
147    #[test]
148    fn save_and_restore_round_trip() {
149        let mut stack = StateStack::new();
150        let mut state = GraphicsState::default();
151        stack.push(&state);
152        assert_eq!(stack.depth(), 1);
153
154        state.ctm = Affine::translate((10.0, 20.0));
155        state.stroke_params.cap = LineCap::Round;
156        state
157            .fill
158            .set_stock(ColorSpace::DeviceRgb, &[1.0, 0.0, 0.0]);
159
160        assert!(stack.pop(&mut state));
161        assert_eq!(state.ctm, Affine::IDENTITY);
162        assert_eq!(state.stroke_params.cap, LineCap::Butt);
163        assert_eq!(&state.fill.components[..], &[0.0]);
164        assert!(stack.is_empty());
165    }
166
167    #[test]
168    fn restoring_an_empty_stack_changes_nothing() {
169        let mut stack = StateStack::new();
170        let mut state = GraphicsState {
171            ctm: Affine::scale(2.0),
172            ..GraphicsState::default()
173        };
174        assert!(!stack.pop(&mut state));
175        // The state is untouched: an unbalanced `Q` is harmless.
176        assert_eq!(state.ctm, Affine::scale(2.0));
177    }
178
179    #[test]
180    fn the_stack_nests_without_a_cap() {
181        let mut stack = StateStack::new();
182        let state = GraphicsState::default();
183        for _ in 0..1000 {
184            stack.push(&state);
185        }
186        assert_eq!(stack.depth(), 1000);
187    }
188}