Skip to main content

edgeparse_core/pdf/
graphics_state.rs

1//! PDF graphics state tracking.
2//!
3//! Manages the graphics state stack including the Current Transformation Matrix (CTM),
4//! text state parameters, and color state needed for text extraction.
5
6/// 2D affine transformation matrix: [a b c d e f]
7/// Represents the transform: x' = a*x + c*y + e, y' = b*x + d*y + f
8#[derive(Debug, Clone, Copy)]
9pub struct Matrix {
10    /// Horizontal scaling component.
11    pub a: f64,
12    /// Horizontal skewing component.
13    pub b: f64,
14    /// Vertical skewing component.
15    pub c: f64,
16    /// Vertical scaling component.
17    pub d: f64,
18    /// Horizontal translation.
19    pub e: f64,
20    /// Vertical translation.
21    pub f: f64,
22}
23
24impl Matrix {
25    /// Identity matrix.
26    pub fn identity() -> Self {
27        Self {
28            a: 1.0,
29            b: 0.0,
30            c: 0.0,
31            d: 1.0,
32            e: 0.0,
33            f: 0.0,
34        }
35    }
36
37    /// Translate matrix.
38    pub fn translate(tx: f64, ty: f64) -> Self {
39        Self {
40            a: 1.0,
41            b: 0.0,
42            c: 0.0,
43            d: 1.0,
44            e: tx,
45            f: ty,
46        }
47    }
48
49    /// Multiply this matrix by another: self × other.
50    pub fn multiply(&self, other: &Matrix) -> Matrix {
51        Matrix {
52            a: self.a * other.a + self.b * other.c,
53            b: self.a * other.b + self.b * other.d,
54            c: self.c * other.a + self.d * other.c,
55            d: self.c * other.b + self.d * other.d,
56            e: self.e * other.a + self.f * other.c + other.e,
57            f: self.e * other.b + self.f * other.d + other.f,
58        }
59    }
60
61    /// Transform a point (x, y) by this matrix.
62    pub fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
63        (
64            self.a * x + self.c * y + self.e,
65            self.b * x + self.d * y + self.f,
66        )
67    }
68
69    /// Get the effective font size (vertical scaling factor).
70    pub fn font_size_factor(&self) -> f64 {
71        (self.b * self.b + self.d * self.d).sqrt()
72    }
73}
74
75impl Default for Matrix {
76    fn default() -> Self {
77        Self::identity()
78    }
79}
80
81/// Text state parameters tracked during content stream processing.
82#[derive(Debug, Clone)]
83pub struct TextState {
84    /// Current font name (resource name like "F1")
85    pub font_name: String,
86    /// Font size in text space units
87    pub font_size: f64,
88    /// Character spacing (Tc)
89    pub char_spacing: f64,
90    /// Word spacing (Tw)
91    pub word_spacing: f64,
92    /// Horizontal scaling (Tz) as percentage (default 100)
93    pub horizontal_scaling: f64,
94    /// Text leading (TL)
95    pub leading: f64,
96    /// Text rise (Ts)
97    pub rise: f64,
98    /// Text rendering mode (Tr)
99    pub render_mode: i32,
100}
101
102impl Default for TextState {
103    fn default() -> Self {
104        Self {
105            font_name: String::new(),
106            font_size: 0.0,
107            char_spacing: 0.0,
108            word_spacing: 0.0,
109            horizontal_scaling: 100.0,
110            leading: 0.0,
111            rise: 0.0,
112            render_mode: 0,
113        }
114    }
115}
116
117/// Full graphics state for PDF content stream processing.
118#[derive(Debug, Clone)]
119pub struct GraphicsState {
120    /// Current transformation matrix
121    pub ctm: Matrix,
122    /// Text matrix (set by BT and text positioning operators)
123    pub text_matrix: Matrix,
124    /// Text line matrix (set by text line positioning operators)
125    pub text_line_matrix: Matrix,
126    /// Text state parameters
127    pub text_state: TextState,
128    /// Fill color — original PDF color components (1=Gray, 3=RGB, 4=CMYK)
129    pub fill_color: Vec<f64>,
130    /// Stroke color — original PDF color components
131    pub stroke_color: Vec<f64>,
132    /// Number of components in current non-stroking color space (1=Gray, 3=RGB, 4=CMYK).
133    /// `0` means Pattern (no numeric components).
134    pub fill_color_space_components: u8,
135    /// Number of components in current stroking color space.
136    /// `0` means Pattern (no numeric components).
137    pub stroke_color_space_components: u8,
138    /// Active non-stroking Pattern resource name (from `scn` with a name operand).
139    pub fill_pattern: Option<Vec<u8>>,
140    /// Active stroking Pattern resource name (from `SCN` with a name operand).
141    pub stroke_pattern: Option<Vec<u8>>,
142}
143
144impl Default for GraphicsState {
145    fn default() -> Self {
146        Self {
147            ctm: Matrix::identity(),
148            text_matrix: Matrix::identity(),
149            text_line_matrix: Matrix::identity(),
150            text_state: TextState::default(),
151            fill_color: vec![0.0], // Black (default DeviceGray per PDF spec)
152            stroke_color: vec![0.0],
153            fill_color_space_components: 1, // Default: DeviceGray
154            stroke_color_space_components: 1,
155            fill_pattern: None,
156            stroke_pattern: None,
157        }
158    }
159}
160
161impl GraphicsState {
162    /// Begin text object: reset text matrix and text line matrix.
163    pub fn begin_text(&mut self) {
164        self.text_matrix = Matrix::identity();
165        self.text_line_matrix = Matrix::identity();
166    }
167
168    /// Get the combined text rendering matrix: text_state.font_size × text_matrix × CTM.
169    pub fn text_rendering_matrix(&self) -> Matrix {
170        let font_matrix = Matrix {
171            a: self.text_state.font_size * (self.text_state.horizontal_scaling / 100.0),
172            b: 0.0,
173            c: 0.0,
174            d: self.text_state.font_size,
175            e: 0.0,
176            f: self.text_state.rise,
177        };
178        let tm_ctm = self.text_matrix.multiply(&self.ctm);
179        font_matrix.multiply(&tm_ctm)
180    }
181
182    /// Get the current text position in user space.
183    pub fn text_position(&self) -> (f64, f64) {
184        let trm = self.text_rendering_matrix();
185        (trm.e, trm.f)
186    }
187
188    /// Get the effective font size in user space.
189    pub fn effective_font_size(&self) -> f64 {
190        let trm = self.text_rendering_matrix();
191        trm.font_size_factor()
192    }
193
194    /// Apply Td (translate text position).
195    pub fn translate_text(&mut self, tx: f64, ty: f64) {
196        let translation = Matrix::translate(tx, ty);
197        self.text_line_matrix = translation.multiply(&self.text_line_matrix);
198        self.text_matrix = self.text_line_matrix;
199    }
200
201    /// Apply Tm (set text matrix directly).
202    pub fn set_text_matrix(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) {
203        self.text_matrix = Matrix { a, b, c, d, e, f };
204        self.text_line_matrix = self.text_matrix;
205    }
206
207    /// Apply T* (move to start of next line).
208    pub fn next_line(&mut self) {
209        self.translate_text(0.0, -self.text_state.leading);
210    }
211
212    /// Advance the text position after showing text (Tj displacement).
213    pub fn advance_text(&mut self, displacement: f64) {
214        let scaled = displacement * self.text_state.horizontal_scaling / 100.0;
215        self.text_matrix.e += scaled * self.text_matrix.a;
216        self.text_matrix.f += scaled * self.text_matrix.b;
217    }
218}
219
220/// Graphics state stack for q/Q save/restore operations.
221#[derive(Default)]
222pub struct GraphicsStateStack {
223    stack: Vec<GraphicsState>,
224    /// Current active graphics state.
225    pub current: GraphicsState,
226}
227
228impl GraphicsStateStack {
229    /// Save current state (q operator).
230    pub fn save(&mut self) {
231        self.stack.push(self.current.clone());
232    }
233
234    /// Restore saved state (Q operator).
235    pub fn restore(&mut self) {
236        if let Some(state) = self.stack.pop() {
237            self.current = state;
238        }
239    }
240
241    /// Apply CTM concatenation (cm operator).
242    pub fn concat_ctm(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) {
243        let new_matrix = Matrix { a, b, c, d, e, f };
244        self.current.ctm = new_matrix.multiply(&self.current.ctm);
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_matrix_identity() {
254        let m = Matrix::identity();
255        let (x, y) = m.transform_point(10.0, 20.0);
256        assert!((x - 10.0).abs() < 1e-10);
257        assert!((y - 20.0).abs() < 1e-10);
258    }
259
260    #[test]
261    fn test_matrix_translate() {
262        let m = Matrix::translate(100.0, 200.0);
263        let (x, y) = m.transform_point(10.0, 20.0);
264        assert!((x - 110.0).abs() < 1e-10);
265        assert!((y - 220.0).abs() < 1e-10);
266    }
267
268    #[test]
269    fn test_matrix_multiply() {
270        let a = Matrix::translate(10.0, 20.0);
271        let b = Matrix::translate(30.0, 40.0);
272        let c = a.multiply(&b);
273        let (x, y) = c.transform_point(0.0, 0.0);
274        assert!((x - 40.0).abs() < 1e-10);
275        assert!((y - 60.0).abs() < 1e-10);
276    }
277
278    #[test]
279    fn test_text_translate() {
280        let mut gs = GraphicsState::default();
281        gs.text_state.font_size = 12.0;
282        gs.begin_text();
283        gs.translate_text(100.0, 700.0);
284        let (x, y) = gs.text_position();
285        assert!((x - 100.0 * 12.0).abs() < 1e-6 || (x - 100.0).abs() < 1e-6);
286        // The text position should reflect the translation
287        assert!(y.abs() > 0.0 || y.abs() < 1e-6);
288    }
289
290    #[test]
291    fn test_graphics_state_stack() {
292        let mut stack = GraphicsStateStack::default();
293        stack.current.text_state.font_size = 12.0;
294        stack.save();
295        stack.current.text_state.font_size = 24.0;
296        assert!((stack.current.text_state.font_size - 24.0).abs() < 1e-10);
297        stack.restore();
298        assert!((stack.current.text_state.font_size - 12.0).abs() < 1e-10);
299    }
300}