Skip to main content

pdfplumber_parse/
interpreter_state.rs

1//! Graphics state stack for the content stream interpreter.
2//!
3//! Implements the PDF graphics state model: a stack of states managed by
4//! `q` (save) and `Q` (restore) operators, with CTM management via `cm`,
5//! and color setting via G/g, RG/rg, K/k, SC/SCN/sc/scn operators.
6
7use pdfplumber_core::geometry::Ctm;
8use pdfplumber_core::painting::{Color, GraphicsState};
9
10use crate::color_space::ResolvedColorSpace;
11
12/// Full interpreter state that combines the CTM with the graphics state.
13///
14/// This is the interpreter-level state that tracks everything needed
15/// during content stream processing. The `q` operator pushes a copy
16/// onto the stack; `Q` restores from the stack.
17#[derive(Debug, Clone)]
18pub struct InterpreterState {
19    /// Current transformation matrix.
20    ctm: Ctm,
21    /// Current graphics state (colors, line width, dash, alpha).
22    graphics_state: GraphicsState,
23    /// Saved state stack for q/Q operators.
24    stack: Vec<SavedState>,
25    /// Current stroking color space (set by CS operator).
26    stroking_color_space: Option<ResolvedColorSpace>,
27    /// Current non-stroking color space (set by cs operator).
28    non_stroking_color_space: Option<ResolvedColorSpace>,
29}
30
31impl PartialEq for InterpreterState {
32    fn eq(&self, other: &Self) -> bool {
33        self.ctm == other.ctm && self.graphics_state == other.graphics_state
34    }
35}
36
37/// A snapshot of the interpreter state saved by the `q` operator.
38#[derive(Debug, Clone)]
39struct SavedState {
40    ctm: Ctm,
41    graphics_state: GraphicsState,
42    stroking_color_space: Option<ResolvedColorSpace>,
43    non_stroking_color_space: Option<ResolvedColorSpace>,
44}
45
46impl Default for InterpreterState {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl InterpreterState {
53    /// Create a new interpreter state with identity CTM and default graphics state.
54    pub fn new() -> Self {
55        Self {
56            ctm: Ctm::identity(),
57            graphics_state: GraphicsState::default(),
58            stack: Vec::new(),
59            stroking_color_space: None,
60            non_stroking_color_space: None,
61        }
62    }
63
64    /// Get the current transformation matrix.
65    pub fn ctm(&self) -> &Ctm {
66        &self.ctm
67    }
68
69    /// Get the current CTM as a 6-element array `[a, b, c, d, e, f]`.
70    pub fn ctm_array(&self) -> [f64; 6] {
71        [
72            self.ctm.a, self.ctm.b, self.ctm.c, self.ctm.d, self.ctm.e, self.ctm.f,
73        ]
74    }
75
76    /// Get the current graphics state.
77    pub fn graphics_state(&self) -> &GraphicsState {
78        &self.graphics_state
79    }
80
81    /// Get a mutable reference to the current graphics state.
82    pub fn graphics_state_mut(&mut self) -> &mut GraphicsState {
83        &mut self.graphics_state
84    }
85
86    /// Returns the current stack depth.
87    pub fn stack_depth(&self) -> usize {
88        self.stack.len()
89    }
90
91    // --- q/Q operators ---
92
93    /// `q` operator: save the current graphics state onto the stack.
94    pub fn save_state(&mut self) {
95        self.stack.push(SavedState {
96            ctm: self.ctm,
97            graphics_state: self.graphics_state.clone(),
98            stroking_color_space: self.stroking_color_space.clone(),
99            non_stroking_color_space: self.non_stroking_color_space.clone(),
100        });
101    }
102
103    /// `Q` operator: restore the most recently saved graphics state.
104    ///
105    /// Returns `false` if the stack is empty (unbalanced Q).
106    pub fn restore_state(&mut self) -> bool {
107        if let Some(saved) = self.stack.pop() {
108            self.ctm = saved.ctm;
109            self.graphics_state = saved.graphics_state;
110            self.stroking_color_space = saved.stroking_color_space;
111            self.non_stroking_color_space = saved.non_stroking_color_space;
112            true
113        } else {
114            false
115        }
116    }
117
118    // --- cm operator ---
119
120    /// `cm` operator: concatenate a matrix with the current CTM.
121    ///
122    /// The new matrix is pre-multiplied: CTM' = new_matrix × CTM_current.
123    /// This follows the PDF spec where `cm` modifies the CTM by pre-concatenating.
124    pub fn concat_matrix(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) {
125        let new_matrix = Ctm::new(a, b, c, d, e, f);
126        self.ctm = new_matrix.concat(&self.ctm);
127    }
128
129    // --- w operator ---
130
131    /// `w` operator: set line width.
132    pub fn set_line_width(&mut self, width: f64) {
133        self.graphics_state.line_width = width;
134    }
135
136    // --- d operator ---
137
138    /// `d` operator: set dash pattern.
139    pub fn set_dash_pattern(&mut self, dash_array: Vec<f64>, dash_phase: f64) {
140        self.graphics_state.set_dash_pattern(dash_array, dash_phase);
141    }
142
143    // --- Color operators ---
144
145    /// `G` operator: set stroking color to DeviceGray.
146    pub fn set_stroking_gray(&mut self, gray: f32) {
147        self.stroking_color_space = None;
148        self.graphics_state.stroke_color = Color::Gray(gray);
149    }
150
151    /// `g` operator: set non-stroking color to DeviceGray.
152    pub fn set_non_stroking_gray(&mut self, gray: f32) {
153        self.non_stroking_color_space = None;
154        self.graphics_state.fill_color = Color::Gray(gray);
155    }
156
157    /// `RG` operator: set stroking color to DeviceRGB.
158    pub fn set_stroking_rgb(&mut self, r: f32, g: f32, b: f32) {
159        self.stroking_color_space = None;
160        self.graphics_state.stroke_color = Color::Rgb(r, g, b);
161    }
162
163    /// `rg` operator: set non-stroking color to DeviceRGB.
164    pub fn set_non_stroking_rgb(&mut self, r: f32, g: f32, b: f32) {
165        self.non_stroking_color_space = None;
166        self.graphics_state.fill_color = Color::Rgb(r, g, b);
167    }
168
169    /// `K` operator: set stroking color to DeviceCMYK.
170    pub fn set_stroking_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) {
171        self.stroking_color_space = None;
172        self.graphics_state.stroke_color = Color::Cmyk(c, m, y, k);
173    }
174
175    /// `k` operator: set non-stroking color to DeviceCMYK.
176    pub fn set_non_stroking_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) {
177        self.non_stroking_color_space = None;
178        self.graphics_state.fill_color = Color::Cmyk(c, m, y, k);
179    }
180
181    /// `SC`/`SCN` operator: set stroking color from components.
182    ///
183    /// If a stroking color space has been set (via CS), uses it to resolve
184    /// the color. Otherwise falls back to inferring from component count.
185    pub fn set_stroking_color(&mut self, components: &[f32]) {
186        self.graphics_state.stroke_color = if let Some(ref cs) = self.stroking_color_space {
187            cs.resolve_color(components)
188        } else {
189            color_from_components(components)
190        };
191    }
192
193    /// `sc`/`scn` operator: set non-stroking color from components.
194    ///
195    /// If a non-stroking color space has been set (via cs), uses it to resolve
196    /// the color. Otherwise falls back to inferring from component count.
197    pub fn set_non_stroking_color(&mut self, components: &[f32]) {
198        self.graphics_state.fill_color = if let Some(ref cs) = self.non_stroking_color_space {
199            cs.resolve_color(components)
200        } else {
201            color_from_components(components)
202        };
203    }
204
205    /// `CS` operator: set the stroking color space.
206    pub fn set_stroking_color_space(&mut self, cs: ResolvedColorSpace) {
207        self.stroking_color_space = Some(cs);
208    }
209
210    /// `cs` operator: set the non-stroking color space.
211    pub fn set_non_stroking_color_space(&mut self, cs: ResolvedColorSpace) {
212        self.non_stroking_color_space = Some(cs);
213    }
214}
215
216/// Convert a slice of color components to a `Color` value.
217fn color_from_components(components: &[f32]) -> Color {
218    match components.len() {
219        1 => Color::Gray(components[0]),
220        3 => Color::Rgb(components[0], components[1], components[2]),
221        4 => Color::Cmyk(components[0], components[1], components[2], components[3]),
222        _ => Color::Other(components.to_vec()),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use pdfplumber_core::geometry::Point;
230    use pdfplumber_core::painting::DashPattern;
231
232    // --- Construction and defaults ---
233
234    #[test]
235    fn test_new_has_identity_ctm() {
236        let state = InterpreterState::new();
237        assert_eq!(*state.ctm(), Ctm::identity());
238    }
239
240    #[test]
241    fn test_new_has_default_graphics_state() {
242        let state = InterpreterState::new();
243        let gs = state.graphics_state();
244        assert_eq!(gs.line_width, 1.0);
245        assert_eq!(gs.stroke_color, Color::black());
246        assert_eq!(gs.fill_color, Color::black());
247        assert!(gs.dash_pattern.is_solid());
248        assert_eq!(gs.stroke_alpha, 1.0);
249        assert_eq!(gs.fill_alpha, 1.0);
250    }
251
252    #[test]
253    fn test_new_has_empty_stack() {
254        let state = InterpreterState::new();
255        assert_eq!(state.stack_depth(), 0);
256    }
257
258    #[test]
259    fn test_default_equals_new() {
260        assert_eq!(InterpreterState::default(), InterpreterState::new());
261    }
262
263    #[test]
264    fn test_ctm_array() {
265        let state = InterpreterState::new();
266        assert_eq!(state.ctm_array(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
267    }
268
269    // --- q/Q: push/pop state ---
270
271    #[test]
272    fn test_save_state_increments_depth() {
273        let mut state = InterpreterState::new();
274        state.save_state();
275        assert_eq!(state.stack_depth(), 1);
276        state.save_state();
277        assert_eq!(state.stack_depth(), 2);
278    }
279
280    #[test]
281    fn test_restore_state_decrements_depth() {
282        let mut state = InterpreterState::new();
283        state.save_state();
284        state.save_state();
285        assert_eq!(state.stack_depth(), 2);
286
287        assert!(state.restore_state());
288        assert_eq!(state.stack_depth(), 1);
289
290        assert!(state.restore_state());
291        assert_eq!(state.stack_depth(), 0);
292    }
293
294    #[test]
295    fn test_restore_on_empty_stack_returns_false() {
296        let mut state = InterpreterState::new();
297        assert!(!state.restore_state());
298    }
299
300    #[test]
301    fn test_save_restore_preserves_ctm() {
302        let mut state = InterpreterState::new();
303
304        // Save, then modify CTM
305        state.save_state();
306        state.concat_matrix(2.0, 0.0, 0.0, 2.0, 10.0, 20.0);
307        assert_ne!(*state.ctm(), Ctm::identity());
308
309        // Restore: CTM should be back to identity
310        state.restore_state();
311        assert_eq!(*state.ctm(), Ctm::identity());
312    }
313
314    #[test]
315    fn test_save_restore_preserves_graphics_state() {
316        let mut state = InterpreterState::new();
317
318        // Save, then modify state
319        state.save_state();
320        state.set_line_width(5.0);
321        state.set_stroking_rgb(1.0, 0.0, 0.0);
322        state.set_non_stroking_gray(0.5);
323        state.set_dash_pattern(vec![3.0, 2.0], 1.0);
324
325        // Verify changes took effect
326        assert_eq!(state.graphics_state().line_width, 5.0);
327        assert_eq!(
328            state.graphics_state().stroke_color,
329            Color::Rgb(1.0, 0.0, 0.0)
330        );
331        assert_eq!(state.graphics_state().fill_color, Color::Gray(0.5));
332
333        // Restore: all should be back to defaults
334        state.restore_state();
335        assert_eq!(state.graphics_state().line_width, 1.0);
336        assert_eq!(state.graphics_state().stroke_color, Color::black());
337        assert_eq!(state.graphics_state().fill_color, Color::black());
338        assert!(state.graphics_state().dash_pattern.is_solid());
339    }
340
341    #[test]
342    fn test_nested_save_restore() {
343        let mut state = InterpreterState::new();
344
345        // Level 0: set red stroke
346        state.set_stroking_rgb(1.0, 0.0, 0.0);
347
348        // Save level 0
349        state.save_state();
350
351        // Level 1: set blue stroke
352        state.set_stroking_rgb(0.0, 0.0, 1.0);
353        assert_eq!(
354            state.graphics_state().stroke_color,
355            Color::Rgb(0.0, 0.0, 1.0)
356        );
357
358        // Save level 1
359        state.save_state();
360
361        // Level 2: set green stroke
362        state.set_stroking_rgb(0.0, 1.0, 0.0);
363        assert_eq!(
364            state.graphics_state().stroke_color,
365            Color::Rgb(0.0, 1.0, 0.0)
366        );
367
368        // Restore to level 1: blue
369        state.restore_state();
370        assert_eq!(
371            state.graphics_state().stroke_color,
372            Color::Rgb(0.0, 0.0, 1.0)
373        );
374
375        // Restore to level 0: red
376        state.restore_state();
377        assert_eq!(
378            state.graphics_state().stroke_color,
379            Color::Rgb(1.0, 0.0, 0.0)
380        );
381    }
382
383    // --- cm: CTM multiplication ---
384
385    #[test]
386    fn test_concat_matrix_translation() {
387        let mut state = InterpreterState::new();
388        state.concat_matrix(1.0, 0.0, 0.0, 1.0, 100.0, 200.0);
389
390        let p = state.ctm().transform_point(Point::new(0.0, 0.0));
391        assert_approx(p.x, 100.0);
392        assert_approx(p.y, 200.0);
393    }
394
395    #[test]
396    fn test_concat_matrix_scaling() {
397        let mut state = InterpreterState::new();
398        state.concat_matrix(2.0, 0.0, 0.0, 3.0, 0.0, 0.0);
399
400        let p = state.ctm().transform_point(Point::new(5.0, 10.0));
401        assert_approx(p.x, 10.0);
402        assert_approx(p.y, 30.0);
403    }
404
405    #[test]
406    fn test_concat_matrix_cumulative() {
407        let mut state = InterpreterState::new();
408
409        // First: scale by 2x
410        state.concat_matrix(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
411        // Second: translate by (10, 20) — in the scaled coordinate system
412        state.concat_matrix(1.0, 0.0, 0.0, 1.0, 10.0, 20.0);
413
414        // Point (0,0) in user space:
415        // After translate: (10, 20) in intermediate space
416        // After scale: (20, 40) in device space
417        let p = state.ctm().transform_point(Point::new(0.0, 0.0));
418        assert_approx(p.x, 20.0);
419        assert_approx(p.y, 40.0);
420    }
421
422    #[test]
423    fn test_concat_identity_no_change() {
424        let mut state = InterpreterState::new();
425        state.concat_matrix(2.0, 0.0, 0.0, 3.0, 10.0, 20.0);
426        let ctm_before = *state.ctm();
427
428        // Concatenate identity — no change
429        state.concat_matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
430        assert_eq!(*state.ctm(), ctm_before);
431    }
432
433    #[test]
434    fn test_ctm_array_after_concat() {
435        let mut state = InterpreterState::new();
436        state.concat_matrix(2.0, 0.0, 0.0, 3.0, 10.0, 20.0);
437        assert_eq!(state.ctm_array(), [2.0, 0.0, 0.0, 3.0, 10.0, 20.0]);
438    }
439
440    // --- w: line width ---
441
442    #[test]
443    fn test_set_line_width() {
444        let mut state = InterpreterState::new();
445        state.set_line_width(3.5);
446        assert_eq!(state.graphics_state().line_width, 3.5);
447    }
448
449    #[test]
450    fn test_set_line_width_zero() {
451        let mut state = InterpreterState::new();
452        state.set_line_width(0.0);
453        assert_eq!(state.graphics_state().line_width, 0.0);
454    }
455
456    // --- d: dash pattern ---
457
458    #[test]
459    fn test_set_dash_pattern() {
460        let mut state = InterpreterState::new();
461        state.set_dash_pattern(vec![3.0, 2.0], 1.0);
462
463        let dp = &state.graphics_state().dash_pattern;
464        assert_eq!(dp.dash_array, vec![3.0, 2.0]);
465        assert_eq!(dp.dash_phase, 1.0);
466        assert!(!dp.is_solid());
467    }
468
469    #[test]
470    fn test_set_dash_pattern_solid() {
471        let mut state = InterpreterState::new();
472        state.set_dash_pattern(vec![3.0, 2.0], 0.0);
473        assert!(!state.graphics_state().dash_pattern.is_solid());
474
475        state.set_dash_pattern(vec![], 0.0);
476        assert!(state.graphics_state().dash_pattern.is_solid());
477    }
478
479    // --- G/g: DeviceGray color ---
480
481    #[test]
482    fn test_set_stroking_gray() {
483        let mut state = InterpreterState::new();
484        state.set_stroking_gray(0.5);
485        assert_eq!(state.graphics_state().stroke_color, Color::Gray(0.5));
486    }
487
488    #[test]
489    fn test_set_non_stroking_gray() {
490        let mut state = InterpreterState::new();
491        state.set_non_stroking_gray(0.75);
492        assert_eq!(state.graphics_state().fill_color, Color::Gray(0.75));
493    }
494
495    // --- RG/rg: DeviceRGB color ---
496
497    #[test]
498    fn test_set_stroking_rgb() {
499        let mut state = InterpreterState::new();
500        state.set_stroking_rgb(1.0, 0.0, 0.0);
501        assert_eq!(
502            state.graphics_state().stroke_color,
503            Color::Rgb(1.0, 0.0, 0.0)
504        );
505    }
506
507    #[test]
508    fn test_set_non_stroking_rgb() {
509        let mut state = InterpreterState::new();
510        state.set_non_stroking_rgb(0.0, 1.0, 0.0);
511        assert_eq!(state.graphics_state().fill_color, Color::Rgb(0.0, 1.0, 0.0));
512    }
513
514    // --- K/k: DeviceCMYK color ---
515
516    #[test]
517    fn test_set_stroking_cmyk() {
518        let mut state = InterpreterState::new();
519        state.set_stroking_cmyk(0.1, 0.2, 0.3, 0.4);
520        assert_eq!(
521            state.graphics_state().stroke_color,
522            Color::Cmyk(0.1, 0.2, 0.3, 0.4)
523        );
524    }
525
526    #[test]
527    fn test_set_non_stroking_cmyk() {
528        let mut state = InterpreterState::new();
529        state.set_non_stroking_cmyk(0.5, 0.6, 0.7, 0.8);
530        assert_eq!(
531            state.graphics_state().fill_color,
532            Color::Cmyk(0.5, 0.6, 0.7, 0.8)
533        );
534    }
535
536    // --- SC/SCN/sc/scn: generic color operators ---
537
538    #[test]
539    fn test_set_stroking_color_1_component_is_gray() {
540        let mut state = InterpreterState::new();
541        state.set_stroking_color(&[0.5]);
542        assert_eq!(state.graphics_state().stroke_color, Color::Gray(0.5));
543    }
544
545    #[test]
546    fn test_set_stroking_color_3_components_is_rgb() {
547        let mut state = InterpreterState::new();
548        state.set_stroking_color(&[1.0, 0.0, 0.0]);
549        assert_eq!(
550            state.graphics_state().stroke_color,
551            Color::Rgb(1.0, 0.0, 0.0)
552        );
553    }
554
555    #[test]
556    fn test_set_stroking_color_4_components_is_cmyk() {
557        let mut state = InterpreterState::new();
558        state.set_stroking_color(&[0.1, 0.2, 0.3, 0.4]);
559        assert_eq!(
560            state.graphics_state().stroke_color,
561            Color::Cmyk(0.1, 0.2, 0.3, 0.4)
562        );
563    }
564
565    #[test]
566    fn test_set_stroking_color_other_component_count() {
567        let mut state = InterpreterState::new();
568        state.set_stroking_color(&[0.1, 0.2]);
569        assert_eq!(
570            state.graphics_state().stroke_color,
571            Color::Other(vec![0.1, 0.2])
572        );
573    }
574
575    #[test]
576    fn test_set_non_stroking_color_1_component() {
577        let mut state = InterpreterState::new();
578        state.set_non_stroking_color(&[0.3]);
579        assert_eq!(state.graphics_state().fill_color, Color::Gray(0.3));
580    }
581
582    #[test]
583    fn test_set_non_stroking_color_3_components() {
584        let mut state = InterpreterState::new();
585        state.set_non_stroking_color(&[0.0, 0.0, 1.0]);
586        assert_eq!(state.graphics_state().fill_color, Color::Rgb(0.0, 0.0, 1.0));
587    }
588
589    #[test]
590    fn test_set_non_stroking_color_5_components_is_other() {
591        let mut state = InterpreterState::new();
592        state.set_non_stroking_color(&[0.1, 0.2, 0.3, 0.4, 0.5]);
593        assert_eq!(
594            state.graphics_state().fill_color,
595            Color::Other(vec![0.1, 0.2, 0.3, 0.4, 0.5])
596        );
597    }
598
599    // --- Color state independence ---
600
601    #[test]
602    fn test_stroking_and_non_stroking_independent() {
603        let mut state = InterpreterState::new();
604        state.set_stroking_rgb(1.0, 0.0, 0.0);
605        state.set_non_stroking_rgb(0.0, 0.0, 1.0);
606
607        assert_eq!(
608            state.graphics_state().stroke_color,
609            Color::Rgb(1.0, 0.0, 0.0)
610        );
611        assert_eq!(state.graphics_state().fill_color, Color::Rgb(0.0, 0.0, 1.0));
612    }
613
614    #[test]
615    fn test_color_changes_across_color_spaces() {
616        let mut state = InterpreterState::new();
617
618        // Start gray
619        state.set_stroking_gray(0.5);
620        assert_eq!(state.graphics_state().stroke_color, Color::Gray(0.5));
621
622        // Switch to RGB
623        state.set_stroking_rgb(1.0, 0.0, 0.0);
624        assert_eq!(
625            state.graphics_state().stroke_color,
626            Color::Rgb(1.0, 0.0, 0.0)
627        );
628
629        // Switch to CMYK
630        state.set_stroking_cmyk(0.0, 1.0, 0.0, 0.0);
631        assert_eq!(
632            state.graphics_state().stroke_color,
633            Color::Cmyk(0.0, 1.0, 0.0, 0.0)
634        );
635    }
636
637    // --- Combined q/Q with all state changes ---
638
639    #[test]
640    fn test_full_state_save_restore_cycle() {
641        let mut state = InterpreterState::new();
642
643        // Set up initial state
644        state.concat_matrix(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
645        state.set_line_width(2.0);
646        state.set_stroking_rgb(1.0, 0.0, 0.0);
647        state.set_non_stroking_gray(0.5);
648        state.set_dash_pattern(vec![5.0, 3.0], 0.0);
649
650        // Save (q)
651        state.save_state();
652
653        // Modify everything
654        state.concat_matrix(1.0, 0.0, 0.0, 1.0, 50.0, 50.0);
655        state.set_line_width(0.5);
656        state.set_stroking_cmyk(0.0, 0.0, 0.0, 1.0);
657        state.set_non_stroking_rgb(0.0, 1.0, 0.0);
658        state.set_dash_pattern(vec![], 0.0);
659
660        // Verify modifications
661        assert_eq!(state.graphics_state().line_width, 0.5);
662        assert_eq!(
663            state.graphics_state().stroke_color,
664            Color::Cmyk(0.0, 0.0, 0.0, 1.0)
665        );
666        assert!(state.graphics_state().dash_pattern.is_solid());
667
668        // Restore (Q) — should revert to pre-save state
669        state.restore_state();
670
671        // Check CTM was restored (scale 2x only)
672        assert_eq!(state.ctm_array(), [2.0, 0.0, 0.0, 2.0, 0.0, 0.0]);
673
674        // Check graphics state was restored
675        assert_eq!(state.graphics_state().line_width, 2.0);
676        assert_eq!(
677            state.graphics_state().stroke_color,
678            Color::Rgb(1.0, 0.0, 0.0)
679        );
680        assert_eq!(state.graphics_state().fill_color, Color::Gray(0.5));
681        assert_eq!(
682            state.graphics_state().dash_pattern,
683            DashPattern::new(vec![5.0, 3.0], 0.0)
684        );
685    }
686
687    #[test]
688    fn test_multiple_unbalanced_restores_return_false() {
689        let mut state = InterpreterState::new();
690        state.save_state();
691
692        assert!(state.restore_state());
693        assert!(!state.restore_state()); // empty stack
694        assert!(!state.restore_state()); // still empty
695    }
696
697    #[test]
698    fn test_graphics_state_mut_access() {
699        let mut state = InterpreterState::new();
700        state.graphics_state_mut().stroke_alpha = 0.5;
701        assert_eq!(state.graphics_state().stroke_alpha, 0.5);
702    }
703
704    // --- Helper ---
705
706    fn assert_approx(actual: f64, expected: f64) {
707        assert!(
708            (actual - expected).abs() < 1e-10,
709            "expected {expected}, got {actual}"
710        );
711    }
712}