Skip to main content

oxidize_pdf/graphics/
extraction.rs

1//! Vector graphics extraction for table detection.
2//!
3//! This module extracts vector line segments from PDF content streams,
4//! which are used for detecting table borders and structure.
5//!
6//! # Overview
7//!
8//! PDF graphics are defined using path construction operators:
9//! - `m` (moveto) - Start new subpath
10//! - `l` (lineto) - Append straight line
11//! - `re` (rectangle) - Append rectangle (decomposes to 4 lines)
12//! - `h` (closepath) - Close current subpath
13//!
14//! Path painting operators:
15//! - `S` - Stroke path
16//! - `s` - Close and stroke
17//! - `f` - Fill with nonzero winding rule
18//! - `F` - Fill with nonzero winding (deprecated)
19//! - `f*` - Fill with even-odd rule
20//! - `B` - Fill and stroke (nonzero winding)
21//! - `b` - Close, fill, and stroke
22//!
23//! # Coordinate System
24//!
25//! PDF uses a coordinate system where (0,0) is at the bottom-left corner.
26//! The Current Transformation Matrix (CTM) transforms user space to device space.
27//!
28//! # Example
29//!
30//! ```rust,no_run
31//! use oxidize_pdf::graphics::extraction::{GraphicsExtractor, ExtractionConfig};
32//! use oxidize_pdf::parser::{PdfReader, PdfDocument};
33//! use std::fs::File;
34//!
35//! let file = File::open("table.pdf")?;
36//! let reader = PdfReader::new(file)?;
37//! let doc = PdfDocument::new(reader);
38//!
39//! let config = ExtractionConfig::default();
40//! let mut extractor = GraphicsExtractor::new(config);
41//! let graphics = extractor.extract_from_page(&doc, 0)?;
42//!
43//! for line in &graphics.lines {
44//!     println!("Line: ({}, {}) -> ({}, {})",
45//!         line.x1, line.y1, line.x2, line.y2);
46//! }
47//! # Ok::<(), Box<dyn std::error::Error>>(())
48//! ```
49
50use crate::parser::content::{ContentOperation, ContentParser};
51use crate::parser::{ParseError, PdfDocument};
52use std::fmt;
53
54/// Orientation of a line segment.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum LineOrientation {
57    /// Horizontal line (y1 == y2)
58    Horizontal,
59    /// Vertical line (x1 == x2)
60    Vertical,
61    /// Diagonal line (neither horizontal nor vertical)
62    Diagonal,
63}
64
65/// A vector line segment extracted from PDF graphics.
66#[derive(Debug, Clone, PartialEq)]
67pub struct VectorLine {
68    /// Start X coordinate
69    pub x1: f64,
70    /// Start Y coordinate
71    pub y1: f64,
72    /// End X coordinate
73    pub x2: f64,
74    /// End Y coordinate
75    pub y2: f64,
76    /// Line orientation
77    pub orientation: LineOrientation,
78    /// Stroke width (line thickness)
79    pub stroke_width: f64,
80    /// Whether this line was stroked (visible)
81    pub is_stroked: bool,
82    /// Stroke color of the line (from graphics state)
83    pub color: Option<crate::graphics::Color>,
84}
85
86impl VectorLine {
87    /// Creates a new vector line.
88    ///
89    /// # Arguments
90    ///
91    /// * `x1`, `y1` - Start coordinates
92    /// * `x2`, `y2` - End coordinates
93    /// * `stroke_width` - Line thickness
94    /// * `is_stroked` - Whether line is visible (stroked)
95    /// * `color` - Stroke color (optional)
96    ///
97    /// # Returns
98    ///
99    /// A new `VectorLine` with computed orientation.
100    pub fn new(
101        x1: f64,
102        y1: f64,
103        x2: f64,
104        y2: f64,
105        stroke_width: f64,
106        is_stroked: bool,
107        color: Option<crate::graphics::Color>,
108    ) -> Self {
109        let orientation = Self::compute_orientation(x1, y1, x2, y2);
110        Self {
111            x1,
112            y1,
113            x2,
114            y2,
115            orientation,
116            stroke_width,
117            is_stroked,
118            color,
119        }
120    }
121
122    /// Computes the orientation of a line segment.
123    ///
124    /// Uses a tolerance of 0.1 points to handle floating-point imprecision.
125    fn compute_orientation(x1: f64, y1: f64, x2: f64, y2: f64) -> LineOrientation {
126        const TOLERANCE: f64 = 0.1;
127
128        let dx = (x2 - x1).abs();
129        let dy = (y2 - y1).abs();
130
131        if dy < TOLERANCE {
132            LineOrientation::Horizontal
133        } else if dx < TOLERANCE {
134            LineOrientation::Vertical
135        } else {
136            LineOrientation::Diagonal
137        }
138    }
139
140    /// Returns the length of the line segment.
141    pub fn length(&self) -> f64 {
142        let dx = self.x2 - self.x1;
143        let dy = self.y2 - self.y1;
144        (dx * dx + dy * dy).sqrt()
145    }
146
147    /// Returns the midpoint of the line segment.
148    pub fn midpoint(&self) -> (f64, f64) {
149        ((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
150    }
151}
152
153/// Container for extracted graphics elements.
154#[derive(Debug, Clone, Default)]
155pub struct ExtractedGraphics {
156    /// Extracted line segments
157    pub lines: Vec<VectorLine>,
158    /// Number of horizontal lines
159    pub horizontal_count: usize,
160    /// Number of vertical lines
161    pub vertical_count: usize,
162}
163
164impl ExtractedGraphics {
165    /// Creates a new empty graphics container.
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Adds a line segment and updates counts.
171    pub fn add_line(&mut self, line: VectorLine) {
172        match line.orientation {
173            LineOrientation::Horizontal => self.horizontal_count += 1,
174            LineOrientation::Vertical => self.vertical_count += 1,
175            LineOrientation::Diagonal => {} // Don't count diagonals for tables
176        }
177        self.lines.push(line);
178    }
179
180    /// Returns only horizontal lines.
181    pub fn horizontal_lines(&self) -> impl Iterator<Item = &VectorLine> {
182        self.lines
183            .iter()
184            .filter(|l| l.orientation == LineOrientation::Horizontal)
185    }
186
187    /// Returns only vertical lines.
188    pub fn vertical_lines(&self) -> impl Iterator<Item = &VectorLine> {
189        self.lines
190            .iter()
191            .filter(|l| l.orientation == LineOrientation::Vertical)
192    }
193
194    /// Checks if there are enough lines for table detection.
195    ///
196    /// A basic table requires at least 2 horizontal and 2 vertical lines.
197    pub fn has_table_structure(&self) -> bool {
198        self.horizontal_count >= 2 && self.vertical_count >= 2
199    }
200}
201
202/// Configuration for graphics extraction.
203#[derive(Debug, Clone)]
204pub struct ExtractionConfig {
205    /// Minimum line length to consider (in points)
206    pub min_line_length: f64,
207    /// Whether to extract diagonal lines
208    pub extract_diagonals: bool,
209    /// Whether to extract only stroked lines
210    pub stroked_only: bool,
211}
212
213impl Default for ExtractionConfig {
214    fn default() -> Self {
215        Self {
216            min_line_length: 1.0,     // Ignore very short lines
217            extract_diagonals: false, // Tables use only H/V lines
218            stroked_only: true,       // Only visible lines
219        }
220    }
221}
222
223/// Graphics extractor for parsing PDF content streams.
224pub struct GraphicsExtractor {
225    config: ExtractionConfig,
226}
227
228impl GraphicsExtractor {
229    /// Creates a new graphics extractor with the given configuration.
230    pub fn new(config: ExtractionConfig) -> Self {
231        Self { config }
232    }
233
234    /// Creates a graphics extractor with default configuration.
235    pub fn default() -> Self {
236        Self::new(ExtractionConfig::default())
237    }
238
239    /// Gets the current configuration.
240    pub fn config(&self) -> &ExtractionConfig {
241        &self.config
242    }
243
244    /// Extracts vector graphics from a PDF page.
245    ///
246    /// # Arguments
247    ///
248    /// * `document` - The PDF document
249    /// * `page_index` - Zero-based page index
250    ///
251    /// # Returns
252    ///
253    /// An `ExtractedGraphics` containing all extracted line segments.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the page cannot be accessed or parsed.
258    pub fn extract_from_page<R: std::io::Read + std::io::Seek>(
259        &mut self,
260        document: &PdfDocument<R>,
261        page_index: usize,
262    ) -> Result<ExtractedGraphics, ExtractionError> {
263        // Get page
264        let page = document
265            .get_page(page_index as u32)
266            .map_err(|e| ExtractionError::ParseError(format!("Failed to get page: {}", e)))?;
267
268        // Get content streams
269        let streams = document
270            .get_page_content_streams(&page)
271            .map_err(|e| ExtractionError::ParseError(format!("Failed to get content: {}", e)))?;
272
273        let mut graphics = ExtractedGraphics::new();
274        let mut state = GraphicsState::new();
275
276        // Process each content stream
277        for stream in streams {
278            let operations = ContentParser::parse(&stream).map_err(|e| {
279                ExtractionError::ParseError(format!("Failed to parse content: {}", e))
280            })?;
281
282            self.process_operations(&operations, &mut state, &mut graphics)?;
283        }
284
285        Ok(graphics)
286    }
287
288    /// Processes a sequence of content stream operations.
289    fn process_operations(
290        &self,
291        operations: &[ContentOperation],
292        state: &mut GraphicsState,
293        graphics: &mut ExtractedGraphics,
294    ) -> Result<(), ExtractionError> {
295        for op in operations {
296            match op {
297                // Graphics state management
298                ContentOperation::SaveGraphicsState => state.save(),
299                ContentOperation::RestoreGraphicsState => state.restore(),
300                ContentOperation::SetLineWidth(w) => state.stroke_width = *w as f64,
301                ContentOperation::SetTransformMatrix(a, b, c, d, e, f) => {
302                    state.apply_transform(
303                        *a as f64, *b as f64, *c as f64, *d as f64, *e as f64, *f as f64,
304                    );
305                }
306
307                // Color operations (Phase 4: Stroke color extraction)
308                ContentOperation::SetStrokingGray(gray) => {
309                    state.stroke_color = Some(crate::graphics::Color::gray(*gray as f64));
310                }
311                ContentOperation::SetStrokingRGB(r, g, b) => {
312                    state.stroke_color =
313                        Some(crate::graphics::Color::rgb(*r as f64, *g as f64, *b as f64));
314                }
315                ContentOperation::SetStrokingCMYK(c, m, y, k) => {
316                    state.stroke_color = Some(crate::graphics::Color::cmyk(
317                        *c as f64, *m as f64, *y as f64, *k as f64,
318                    ));
319                }
320
321                // Path construction
322                ContentOperation::MoveTo(x, y) => {
323                    let (tx, ty) = state.transform_point(*x as f64, *y as f64);
324                    state.move_to(tx, ty);
325                }
326                ContentOperation::LineTo(x, y) => {
327                    let (tx, ty) = state.transform_point(*x as f64, *y as f64);
328                    state.line_to(tx, ty);
329                }
330                ContentOperation::Rectangle(x, y, width, height) => {
331                    self.extract_rectangle_lines(
332                        *x as f64,
333                        *y as f64,
334                        *width as f64,
335                        *height as f64,
336                        state,
337                        graphics,
338                    );
339                }
340                ContentOperation::ClosePath => {
341                    state.close_path();
342                }
343
344                // Path painting (triggers line extraction)
345                ContentOperation::Stroke | ContentOperation::CloseStroke => {
346                    self.extract_path_lines(state, graphics, true);
347                    state.clear_path();
348                }
349                ContentOperation::Fill | ContentOperation::FillEvenOdd => {
350                    if !self.config.stroked_only {
351                        self.extract_path_lines(state, graphics, false);
352                    }
353                    state.clear_path();
354                }
355
356                _ => {} // Ignore other operators
357            }
358        }
359
360        Ok(())
361    }
362
363    /// Extracts lines from a rectangle operation.
364    ///
365    /// Transforms all 4 corners using the current CTM to handle rotations and scaling.
366    fn extract_rectangle_lines(
367        &self,
368        x: f64,
369        y: f64,
370        width: f64,
371        height: f64,
372        state: &GraphicsState,
373        graphics: &mut ExtractedGraphics,
374    ) {
375        let stroke_width = state.stroke_width;
376
377        // Transform all 4 corners
378        let (x1, y1) = state.transform_point(x, y); // Bottom-left
379        let (x2, y2) = state.transform_point(x + width, y); // Bottom-right
380        let (x3, y3) = state.transform_point(x + width, y + height); // Top-right
381        let (x4, y4) = state.transform_point(x, y + height); // Top-left
382
383        // Bottom edge
384        graphics.add_line(VectorLine::new(x1, y1, x2, y2, stroke_width, true, None));
385
386        // Right edge
387        graphics.add_line(VectorLine::new(x2, y2, x3, y3, stroke_width, true, None));
388
389        // Top edge
390        graphics.add_line(VectorLine::new(x3, y3, x4, y4, stroke_width, true, None));
391
392        // Left edge
393        graphics.add_line(VectorLine::new(x4, y4, x1, y1, stroke_width, true, None));
394    }
395
396    /// Extracts lines from the current path.
397    fn extract_path_lines(
398        &self,
399        state: &GraphicsState,
400        graphics: &mut ExtractedGraphics,
401        is_stroked: bool,
402    ) {
403        let stroke_width = state.stroke_width;
404
405        for segment in &state.path {
406            let PathSegment::Line { x1, y1, x2, y2 } = segment;
407            let line = VectorLine::new(
408                *x1,
409                *y1,
410                *x2,
411                *y2,
412                stroke_width,
413                is_stroked,
414                state.stroke_color,
415            );
416
417            // Apply filters
418            if self.config.stroked_only && !is_stroked {
419                continue;
420            }
421
422            if line.length() < self.config.min_line_length {
423                continue;
424            }
425
426            if !self.config.extract_diagonals && line.orientation == LineOrientation::Diagonal {
427                continue;
428            }
429
430            graphics.add_line(line);
431        }
432    }
433}
434
435/// Graphics state for tracking PDF drawing state.
436struct GraphicsState {
437    /// Current transformation matrix [a, b, c, d, e, f]
438    ctm: [f64; 6],
439    /// Current stroke width
440    stroke_width: f64,
441    /// Current stroke color
442    stroke_color: Option<crate::graphics::Color>,
443    /// Current path being constructed
444    path: Vec<PathSegment>,
445    /// Current pen position
446    current_point: Option<(f64, f64)>,
447    /// Saved graphics states (for q/Q operators)
448    state_stack: Vec<SavedState>,
449}
450
451/// Saved graphics state for q/Q operators.
452#[derive(Clone)]
453struct SavedState {
454    ctm: [f64; 6],
455    stroke_width: f64,
456    stroke_color: Option<crate::graphics::Color>,
457}
458
459/// Path segment types.
460#[derive(Debug, Clone)]
461enum PathSegment {
462    Line { x1: f64, y1: f64, x2: f64, y2: f64 },
463}
464
465impl GraphicsState {
466    fn new() -> Self {
467        Self {
468            ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], // Identity matrix
469            stroke_width: 1.0,
470            stroke_color: None,
471            path: Vec::new(),
472            current_point: None,
473            state_stack: Vec::new(),
474        }
475    }
476
477    fn save(&mut self) {
478        self.state_stack.push(SavedState {
479            ctm: self.ctm,
480            stroke_width: self.stroke_width,
481            stroke_color: self.stroke_color,
482        });
483    }
484
485    fn restore(&mut self) {
486        if let Some(saved) = self.state_stack.pop() {
487            self.ctm = saved.ctm;
488            self.stroke_width = saved.stroke_width;
489            self.stroke_color = saved.stroke_color;
490        }
491    }
492
493    fn apply_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) {
494        let [a0, b0, c0, d0, e0, f0] = self.ctm;
495        self.ctm = [
496            a * a0 + b * c0,
497            a * b0 + b * d0,
498            c * a0 + d * c0,
499            c * b0 + d * d0,
500            e * a0 + f * c0 + e0,
501            e * b0 + f * d0 + f0,
502        ];
503    }
504
505    /// Transforms a point using the current transformation matrix.
506    ///
507    /// Applies the CTM to convert user space coordinates to device space.
508    fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
509        let [a, b, c, d, e, f] = self.ctm;
510        let tx = a * x + c * y + e;
511        let ty = b * x + d * y + f;
512        (tx, ty)
513    }
514
515    fn move_to(&mut self, x: f64, y: f64) {
516        self.current_point = Some((x, y));
517    }
518
519    fn line_to(&mut self, x: f64, y: f64) {
520        if let Some((x1, y1)) = self.current_point {
521            self.path.push(PathSegment::Line {
522                x1,
523                y1,
524                x2: x,
525                y2: y,
526            });
527            self.current_point = Some((x, y));
528        }
529    }
530
531    fn close_path(&mut self) {
532        // Close path by adding line from current point back to the start
533        if let Some((start_x, start_y)) = self.path.first().map(|seg| match seg {
534            PathSegment::Line { x1, y1, .. } => (*x1, *y1),
535        }) {
536            if let Some((x, y)) = self.current_point {
537                // Only add closing line if current point is different from start
538                const EPSILON: f64 = 0.01;
539                if (x - start_x).abs() > EPSILON || (y - start_y).abs() > EPSILON {
540                    self.path.push(PathSegment::Line {
541                        x1: x,
542                        y1: y,
543                        x2: start_x,
544                        y2: start_y,
545                    });
546                    self.current_point = Some((start_x, start_y));
547                }
548            }
549        }
550    }
551
552    fn clear_path(&mut self) {
553        self.path.clear();
554        self.current_point = None;
555    }
556}
557
558/// Error type for graphics extraction.
559#[derive(Debug)]
560pub enum ExtractionError {
561    /// Invalid graphics operator
562    InvalidOperator(String),
563    /// Malformed operand
564    InvalidOperand(String),
565    /// I/O error
566    IoError(std::io::Error),
567    /// Parser error
568    ParseError(String),
569}
570
571impl fmt::Display for ExtractionError {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        match self {
574            Self::InvalidOperator(op) => write!(f, "Invalid graphics operator: {}", op),
575            Self::InvalidOperand(msg) => write!(f, "Invalid operand: {}", msg),
576            Self::IoError(e) => write!(f, "I/O error: {}", e),
577            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
578        }
579    }
580}
581
582impl std::error::Error for ExtractionError {
583    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
584        match self {
585            Self::IoError(e) => Some(e),
586            _ => None,
587        }
588    }
589}
590
591impl From<std::io::Error> for ExtractionError {
592    fn from(err: std::io::Error) -> Self {
593        Self::IoError(err)
594    }
595}
596
597impl From<ParseError> for ExtractionError {
598    fn from(err: ParseError) -> Self {
599        Self::ParseError(format!("{}", err))
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_line_orientation_horizontal() {
609        let line = VectorLine::new(100.0, 200.0, 300.0, 200.0, 1.0, true, None);
610        assert_eq!(line.orientation, LineOrientation::Horizontal);
611    }
612
613    #[test]
614    fn test_line_orientation_vertical() {
615        let line = VectorLine::new(100.0, 200.0, 100.0, 400.0, 1.0, true, None);
616        assert_eq!(line.orientation, LineOrientation::Vertical);
617    }
618
619    #[test]
620    fn test_line_orientation_diagonal() {
621        let line = VectorLine::new(100.0, 200.0, 300.0, 400.0, 1.0, true, None);
622        assert_eq!(line.orientation, LineOrientation::Diagonal);
623    }
624
625    #[test]
626    fn test_line_orientation_tolerance() {
627        // Almost horizontal (within tolerance)
628        let line = VectorLine::new(100.0, 200.0, 300.0, 200.05, 1.0, true, None);
629        assert_eq!(line.orientation, LineOrientation::Horizontal);
630
631        // Almost vertical (within tolerance)
632        let line = VectorLine::new(100.0, 200.0, 100.05, 400.0, 1.0, true, None);
633        assert_eq!(line.orientation, LineOrientation::Vertical);
634    }
635
636    #[test]
637    fn test_line_length() {
638        let line = VectorLine::new(0.0, 0.0, 3.0, 4.0, 1.0, true, None);
639        assert!((line.length() - 5.0).abs() < 0.001); // 3-4-5 triangle
640    }
641
642    #[test]
643    fn test_line_midpoint() {
644        let line = VectorLine::new(100.0, 200.0, 300.0, 400.0, 1.0, true, None);
645        let (mx, my) = line.midpoint();
646        assert!((mx - 200.0).abs() < 0.001);
647        assert!((my - 300.0).abs() < 0.001);
648    }
649
650    #[test]
651    fn test_extracted_graphics_add_line() {
652        let mut graphics = ExtractedGraphics::new();
653
654        graphics.add_line(VectorLine::new(0.0, 0.0, 100.0, 0.0, 1.0, true, None)); // H
655        graphics.add_line(VectorLine::new(0.0, 0.0, 0.0, 100.0, 1.0, true, None)); // V
656        graphics.add_line(VectorLine::new(0.0, 0.0, 100.0, 100.0, 1.0, true, None)); // D
657
658        assert_eq!(graphics.horizontal_count, 1);
659        assert_eq!(graphics.vertical_count, 1);
660        assert_eq!(graphics.lines.len(), 3);
661    }
662
663    #[test]
664    fn test_extracted_graphics_iterators() {
665        let mut graphics = ExtractedGraphics::new();
666
667        graphics.add_line(VectorLine::new(0.0, 0.0, 100.0, 0.0, 1.0, true, None)); // H
668        graphics.add_line(VectorLine::new(0.0, 0.0, 0.0, 100.0, 1.0, true, None)); // V
669        graphics.add_line(VectorLine::new(0.0, 100.0, 100.0, 100.0, 1.0, true, None)); // H
670
671        assert_eq!(graphics.horizontal_lines().count(), 2);
672        assert_eq!(graphics.vertical_lines().count(), 1);
673    }
674
675    #[test]
676    fn test_has_table_structure() {
677        let mut graphics = ExtractedGraphics::new();
678
679        // Not enough lines
680        assert!(!graphics.has_table_structure());
681
682        // Add 2 horizontal, 1 vertical (insufficient)
683        graphics.add_line(VectorLine::new(0.0, 0.0, 100.0, 0.0, 1.0, true, None));
684        graphics.add_line(VectorLine::new(0.0, 100.0, 100.0, 100.0, 1.0, true, None));
685        graphics.add_line(VectorLine::new(0.0, 0.0, 0.0, 100.0, 1.0, true, None));
686        assert!(!graphics.has_table_structure());
687
688        // Add 2nd vertical (sufficient)
689        graphics.add_line(VectorLine::new(100.0, 0.0, 100.0, 100.0, 1.0, true, None));
690        assert!(graphics.has_table_structure());
691    }
692
693    #[test]
694    fn test_extraction_config_default() {
695        let config = ExtractionConfig::default();
696        assert_eq!(config.min_line_length, 1.0);
697        assert!(!config.extract_diagonals);
698        assert!(config.stroked_only);
699    }
700
701    // CTM (Current Transformation Matrix) tests
702    #[test]
703    fn test_ctm_transform_point_identity() {
704        let state = GraphicsState::new();
705        let (tx, ty) = state.transform_point(100.0, 200.0);
706        assert!((tx - 100.0).abs() < 0.001);
707        assert!((ty - 200.0).abs() < 0.001);
708    }
709
710    #[test]
711    fn test_ctm_transform_point_translation() {
712        let mut state = GraphicsState::new();
713        // Translate by (50, 75)
714        state.apply_transform(1.0, 0.0, 0.0, 1.0, 50.0, 75.0);
715
716        let (tx, ty) = state.transform_point(100.0, 200.0);
717        assert!((tx - 150.0).abs() < 0.001); // 100 + 50
718        assert!((ty - 275.0).abs() < 0.001); // 200 + 75
719    }
720
721    #[test]
722    fn test_ctm_transform_point_scale() {
723        let mut state = GraphicsState::new();
724        // Scale by 2x
725        state.apply_transform(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
726
727        let (tx, ty) = state.transform_point(100.0, 200.0);
728        assert!((tx - 200.0).abs() < 0.001); // 100 * 2
729        assert!((ty - 400.0).abs() < 0.001); // 200 * 2
730    }
731
732    #[test]
733    fn test_ctm_transform_point_combined() {
734        let mut state = GraphicsState::new();
735        // Scale 2x + translate (10, 20)
736        state.apply_transform(2.0, 0.0, 0.0, 2.0, 10.0, 20.0);
737
738        let (tx, ty) = state.transform_point(5.0, 5.0);
739        assert!((tx - 20.0).abs() < 0.001); // 5*2 + 10
740        assert!((ty - 30.0).abs() < 0.001); // 5*2 + 20
741    }
742
743    #[test]
744    fn test_graphics_state_save_restore() {
745        let mut state = GraphicsState::new();
746        state.stroke_width = 2.0;
747        state.apply_transform(2.0, 0.0, 0.0, 2.0, 10.0, 20.0);
748
749        state.save();
750        state.stroke_width = 5.0;
751        state.apply_transform(1.0, 0.0, 0.0, 1.0, 50.0, 50.0);
752
753        state.restore();
754        assert_eq!(state.stroke_width, 2.0);
755
756        // Verify CTM was restored
757        let (tx, ty) = state.transform_point(5.0, 5.0);
758        assert!((tx - 20.0).abs() < 0.001);
759        assert!((ty - 30.0).abs() < 0.001);
760    }
761
762    #[test]
763    fn test_graphics_state_nested_save_restore() {
764        let mut state = GraphicsState::new();
765        state.stroke_width = 2.0;
766
767        state.save();
768        state.stroke_width = 5.0;
769
770        state.save();
771        state.stroke_width = 10.0;
772
773        state.restore();
774        assert_eq!(state.stroke_width, 5.0);
775
776        state.restore();
777        assert_eq!(state.stroke_width, 2.0);
778
779        // Restore on empty stack should be no-op
780        state.restore();
781        assert_eq!(state.stroke_width, 2.0);
782    }
783
784    #[test]
785    fn test_close_path_creates_closing_line() {
786        let mut state = GraphicsState::new();
787
788        // Create a triangle path
789        state.move_to(100.0, 100.0);
790        state.line_to(200.0, 100.0);
791        state.line_to(200.0, 200.0);
792        state.close_path();
793
794        // Should have 3 lines: 2 explicit + 1 from closepath
795        assert_eq!(state.path.len(), 3);
796
797        // Last line should close back to start
798        let PathSegment::Line { x1, y1, x2, y2 } = &state.path[2];
799        assert!((*x1 - 200.0).abs() < 0.01);
800        assert!((*y1 - 200.0).abs() < 0.01);
801        assert!((*x2 - 100.0).abs() < 0.01);
802        assert!((*y2 - 100.0).abs() < 0.01);
803    }
804
805    #[test]
806    fn test_close_path_no_duplicate_if_already_closed() {
807        let mut state = GraphicsState::new();
808
809        // Create a closed square manually
810        state.move_to(100.0, 100.0);
811        state.line_to(200.0, 100.0);
812        state.line_to(200.0, 200.0);
813        state.line_to(100.0, 200.0);
814        state.line_to(100.0, 100.0); // Manually close
815        state.close_path(); // Should not add duplicate
816
817        // Should have 4 lines (not 5)
818        assert_eq!(state.path.len(), 4);
819    }
820}