Skip to main content

pdfplumber_core/
shapes.rs

1//! Line and Rect extraction from painted paths.
2//!
3//! Converts painted PDF paths into geometric shapes (Line, Rect) with
4//! coordinates in top-left origin system (y-flipped from PDF's bottom-left).
5
6use crate::geometry::{Orientation, Point};
7use crate::painting::{Color, PaintedPath};
8use crate::path::PathSegment;
9
10/// Type alias preserving backward compatibility.
11pub type LineOrientation = Orientation;
12
13/// A line segment extracted from a painted path.
14///
15/// Coordinates use pdfplumber's top-left origin system.
16#[derive(Debug, Clone, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct Line {
19    /// Left x coordinate.
20    pub x0: f64,
21    /// Top y coordinate (distance from top of page).
22    pub top: f64,
23    /// Right x coordinate.
24    pub x1: f64,
25    /// Bottom y coordinate (distance from top of page).
26    pub bottom: f64,
27    /// Line width (stroke width from graphics state).
28    pub line_width: f64,
29    /// Stroking color.
30    pub stroke_color: Color,
31    /// Line orientation classification.
32    pub orientation: Orientation,
33}
34
35/// A curve extracted from a painted path (cubic Bezier segment).
36///
37/// Coordinates use pdfplumber's top-left origin system.
38#[derive(Debug, Clone, PartialEq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub struct Curve {
41    /// Bounding box left x.
42    pub x0: f64,
43    /// Bounding box top y (distance from top of page).
44    pub top: f64,
45    /// Bounding box right x.
46    pub x1: f64,
47    /// Bounding box bottom y (distance from top of page).
48    pub bottom: f64,
49    /// All points in top-left origin: [start, cp1, cp2, end].
50    pub pts: Vec<(f64, f64)>,
51    /// Line width (stroke width from graphics state).
52    pub line_width: f64,
53    /// Whether the curve is stroked.
54    pub stroke: bool,
55    /// Whether the curve is filled.
56    pub fill: bool,
57    /// Stroking color.
58    pub stroke_color: Color,
59    /// Fill color.
60    pub fill_color: Color,
61}
62
63/// A rectangle extracted from a painted path.
64///
65/// Coordinates use pdfplumber's top-left origin system.
66#[derive(Debug, Clone, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct Rect {
69    /// Left x coordinate.
70    pub x0: f64,
71    /// Top y coordinate (distance from top of page).
72    pub top: f64,
73    /// Right x coordinate.
74    pub x1: f64,
75    /// Bottom y coordinate (distance from top of page).
76    pub bottom: f64,
77    /// Line width (stroke width from graphics state).
78    pub line_width: f64,
79    /// Whether the rectangle is stroked.
80    pub stroke: bool,
81    /// Whether the rectangle is filled.
82    pub fill: bool,
83    /// Stroking color.
84    pub stroke_color: Color,
85    /// Fill color.
86    pub fill_color: Color,
87}
88
89impl Rect {
90    /// Width of the rectangle.
91    pub fn width(&self) -> f64 {
92        self.x1 - self.x0
93    }
94
95    /// Height of the rectangle.
96    pub fn height(&self) -> f64 {
97        self.bottom - self.top
98    }
99}
100
101/// Tolerance for floating-point comparison when detecting axis-aligned shapes.
102const AXIS_TOLERANCE: f64 = 1e-6;
103
104/// Classify line orientation based on start and end points (already y-flipped).
105fn classify_orientation(x0: f64, y0: f64, x1: f64, y1: f64) -> Orientation {
106    let dx = (x1 - x0).abs();
107    let dy = (y1 - y0).abs();
108    if dy < AXIS_TOLERANCE {
109        Orientation::Horizontal
110    } else if dx < AXIS_TOLERANCE {
111        Orientation::Vertical
112    } else {
113        Orientation::Diagonal
114    }
115}
116
117/// Flip a y-coordinate from PDF bottom-left origin to top-left origin.
118fn flip_y(y: f64, page_height: f64) -> f64 {
119    page_height - y
120}
121
122/// Try to detect an axis-aligned rectangle from a subpath's vertices.
123///
124/// Returns `Some((x0, top, x1, bottom))` in top-left origin if the vertices
125/// form an axis-aligned rectangle, `None` otherwise.
126fn try_detect_rect(vertices: &[Point], page_height: f64) -> Option<(f64, f64, f64, f64)> {
127    // Need exactly 4 unique vertices for a rectangle
128    if vertices.len() != 4 {
129        return None;
130    }
131
132    // Check that all edges are axis-aligned (horizontal or vertical)
133    for i in 0..4 {
134        let a = &vertices[i];
135        let b = &vertices[(i + 1) % 4];
136        let dx = (b.x - a.x).abs();
137        let dy = (b.y - a.y).abs();
138        // Each edge must be either horizontal or vertical
139        if dx > AXIS_TOLERANCE && dy > AXIS_TOLERANCE {
140            return None;
141        }
142    }
143
144    // Compute bounding box from all vertices
145    let xs: Vec<f64> = vertices.iter().map(|p| p.x).collect();
146    let ys: Vec<f64> = vertices.iter().map(|p| flip_y(p.y, page_height)).collect();
147
148    let x0 = xs.iter().cloned().fold(f64::INFINITY, f64::min);
149    let x1 = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
150    let top = ys.iter().cloned().fold(f64::INFINITY, f64::min);
151    let bottom = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
152
153    Some((x0, top, x1, bottom))
154}
155
156/// Extract subpaths from a path's segments.
157///
158/// Each subpath starts with a MoveTo and contains subsequent segments
159/// until the next MoveTo or end of segments.
160fn extract_subpaths(segments: &[PathSegment]) -> Vec<&[PathSegment]> {
161    let mut subpaths = Vec::new();
162    let mut start = 0;
163
164    for (i, seg) in segments.iter().enumerate() {
165        if i > 0 && matches!(seg, PathSegment::MoveTo(_)) {
166            if start < i {
167                subpaths.push(&segments[start..i]);
168            }
169            start = i;
170        }
171    }
172    if start < segments.len() {
173        subpaths.push(&segments[start..]);
174    }
175
176    subpaths
177}
178
179/// Collect vertices from a subpath's segments.
180///
181/// Returns the list of unique vertices (endpoints of line segments).
182/// ClosePath adds the first vertex as the closing point.
183fn collect_vertices(subpath: &[PathSegment]) -> Vec<Point> {
184    let mut vertices = Vec::new();
185    let mut has_curves = false;
186
187    for seg in subpath {
188        match seg {
189            PathSegment::MoveTo(p) => {
190                vertices.push(*p);
191            }
192            PathSegment::LineTo(p) => {
193                vertices.push(*p);
194            }
195            PathSegment::CurveTo { .. } => {
196                has_curves = true;
197            }
198            PathSegment::ClosePath => {
199                // ClosePath implicitly draws a line back to the start.
200                // We don't need to add the start vertex again for detection.
201            }
202        }
203    }
204
205    // If there are curves, this can't be a simple rectangle or line set
206    if has_curves {
207        return Vec::new();
208    }
209
210    vertices
211}
212
213/// Check if a subpath is closed (has a ClosePath segment or start == end).
214fn is_closed(subpath: &[PathSegment], vertices: &[Point]) -> bool {
215    if subpath.iter().any(|s| matches!(s, PathSegment::ClosePath)) {
216        return true;
217    }
218    // Also check if start and end points coincide
219    if vertices.len() >= 2 {
220        let first = vertices[0];
221        let last = vertices[vertices.len() - 1];
222        return (first.x - last.x).abs() < AXIS_TOLERANCE
223            && (first.y - last.y).abs() < AXIS_TOLERANCE;
224    }
225    false
226}
227
228/// Check if a subpath contains any curve segments.
229fn has_curves(subpath: &[PathSegment]) -> bool {
230    subpath
231        .iter()
232        .any(|s| matches!(s, PathSegment::CurveTo { .. }))
233}
234
235/// Extract Line, Rect, and Curve objects from a painted path.
236///
237/// Coordinates are converted from PDF's bottom-left origin to pdfplumber's
238/// top-left origin using the provided `page_height`.
239///
240/// Rectangle detection:
241/// - Axis-aligned closed paths with exactly 4 vertices (no curves)
242/// - Both from `re` operator and manual 4-line constructions
243///
244/// Line extraction:
245/// - Each LineTo segment in a non-rectangle, non-curve subpath becomes a Line
246/// - Stroked paths produce lines; non-stroked paths do not produce lines
247///
248/// Curve extraction:
249/// - Each CurveTo segment becomes a Curve object with control points
250/// - LineTo segments in curve-containing subpaths also become Lines (if stroked)
251pub fn extract_shapes(
252    painted: &PaintedPath,
253    page_height: f64,
254) -> (Vec<Line>, Vec<Rect>, Vec<Curve>) {
255    let mut lines = Vec::new();
256    let mut rects = Vec::new();
257    let mut curves = Vec::new();
258
259    let subpaths = extract_subpaths(&painted.path.segments);
260
261    for subpath in subpaths {
262        // If the subpath has curves, extract curve objects
263        if has_curves(subpath) {
264            extract_curves_from_subpath(subpath, painted, page_height, &mut curves, &mut lines);
265            continue;
266        }
267
268        let vertices = collect_vertices(subpath);
269        if vertices.is_empty() {
270            continue;
271        }
272
273        let closed = is_closed(subpath, &vertices);
274
275        // Try to detect rectangle from closed 4-vertex subpath
276        if closed && vertices.len() == 4 {
277            if let Some((x0, top, x1, bottom)) = try_detect_rect(&vertices, page_height) {
278                rects.push(Rect {
279                    x0,
280                    top,
281                    x1,
282                    bottom,
283                    line_width: painted.line_width,
284                    stroke: painted.stroke,
285                    fill: painted.fill,
286                    stroke_color: painted.stroke_color.clone(),
287                    fill_color: painted.fill_color.clone(),
288                });
289                continue;
290            }
291        }
292
293        // Also check 5 vertices where the last == first (rectangle without ClosePath segment)
294        if closed && vertices.len() == 5 {
295            let first = vertices[0];
296            let last = vertices[4];
297            if (first.x - last.x).abs() < AXIS_TOLERANCE
298                && (first.y - last.y).abs() < AXIS_TOLERANCE
299            {
300                if let Some((x0, top, x1, bottom)) = try_detect_rect(&vertices[..4], page_height) {
301                    rects.push(Rect {
302                        x0,
303                        top,
304                        x1,
305                        bottom,
306                        line_width: painted.line_width,
307                        stroke: painted.stroke,
308                        fill: painted.fill,
309                        stroke_color: painted.stroke_color.clone(),
310                        fill_color: painted.fill_color.clone(),
311                    });
312                    continue;
313                }
314            }
315        }
316
317        // Extract individual lines from stroked paths
318        if !painted.stroke {
319            continue;
320        }
321
322        extract_lines_from_subpath(subpath, &vertices, painted, page_height, &mut lines);
323    }
324
325    (lines, rects, curves)
326}
327
328/// Extract lines from a non-curve subpath.
329fn extract_lines_from_subpath(
330    subpath: &[PathSegment],
331    vertices: &[Point],
332    painted: &PaintedPath,
333    page_height: f64,
334    lines: &mut Vec<Line>,
335) {
336    let mut prev_point: Option<Point> = None;
337    for seg in subpath {
338        match seg {
339            PathSegment::MoveTo(p) => {
340                prev_point = Some(*p);
341            }
342            PathSegment::LineTo(p) => {
343                if let Some(start) = prev_point {
344                    push_line(start, *p, painted, page_height, lines);
345                }
346                prev_point = Some(*p);
347            }
348            PathSegment::ClosePath => {
349                if let (Some(current), Some(start_pt)) = (prev_point, vertices.first().copied()) {
350                    if (current.x - start_pt.x).abs() > AXIS_TOLERANCE
351                        || (current.y - start_pt.y).abs() > AXIS_TOLERANCE
352                    {
353                        push_line(current, start_pt, painted, page_height, lines);
354                    }
355                }
356                prev_point = vertices.first().copied();
357            }
358            PathSegment::CurveTo { .. } => {}
359        }
360    }
361}
362
363/// Push a Line from two points (PDF coords) into the lines vector.
364fn push_line(
365    start: Point,
366    end: Point,
367    painted: &PaintedPath,
368    page_height: f64,
369    lines: &mut Vec<Line>,
370) {
371    let fy0 = flip_y(start.y, page_height);
372    let fy1 = flip_y(end.y, page_height);
373
374    let x0 = start.x.min(end.x);
375    let x1 = start.x.max(end.x);
376    let top = fy0.min(fy1);
377    let bottom = fy0.max(fy1);
378    let orientation = classify_orientation(start.x, fy0, end.x, fy1);
379
380    lines.push(Line {
381        x0,
382        top,
383        x1,
384        bottom,
385        line_width: painted.line_width,
386        stroke_color: painted.stroke_color.clone(),
387        orientation,
388    });
389}
390
391/// Extract curves (and lines from mixed subpaths) from a subpath containing CurveTo segments.
392fn extract_curves_from_subpath(
393    subpath: &[PathSegment],
394    painted: &PaintedPath,
395    page_height: f64,
396    curves: &mut Vec<Curve>,
397    lines: &mut Vec<Line>,
398) {
399    let mut prev_point: Option<Point> = None;
400    let mut subpath_start: Option<Point> = None;
401
402    for seg in subpath {
403        match seg {
404            PathSegment::MoveTo(p) => {
405                prev_point = Some(*p);
406                subpath_start = Some(*p);
407            }
408            PathSegment::LineTo(p) => {
409                if painted.stroke {
410                    if let Some(start) = prev_point {
411                        push_line(start, *p, painted, page_height, lines);
412                    }
413                }
414                prev_point = Some(*p);
415            }
416            PathSegment::CurveTo { cp1, cp2, end } => {
417                if let Some(start) = prev_point {
418                    // Collect all x/y coordinates for bbox
419                    let all_x = [start.x, cp1.x, cp2.x, end.x];
420                    let all_y = [
421                        flip_y(start.y, page_height),
422                        flip_y(cp1.y, page_height),
423                        flip_y(cp2.y, page_height),
424                        flip_y(end.y, page_height),
425                    ];
426
427                    let x0 = all_x.iter().cloned().fold(f64::INFINITY, f64::min);
428                    let x1 = all_x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
429                    let top = all_y.iter().cloned().fold(f64::INFINITY, f64::min);
430                    let bottom = all_y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
431
432                    curves.push(Curve {
433                        x0,
434                        top,
435                        x1,
436                        bottom,
437                        pts: vec![
438                            (start.x, flip_y(start.y, page_height)),
439                            (cp1.x, flip_y(cp1.y, page_height)),
440                            (cp2.x, flip_y(cp2.y, page_height)),
441                            (end.x, flip_y(end.y, page_height)),
442                        ],
443                        line_width: painted.line_width,
444                        stroke: painted.stroke,
445                        fill: painted.fill,
446                        stroke_color: painted.stroke_color.clone(),
447                        fill_color: painted.fill_color.clone(),
448                    });
449                }
450                prev_point = Some(*end);
451            }
452            PathSegment::ClosePath => {
453                // ClosePath draws a line back to the subpath start
454                if painted.stroke {
455                    if let (Some(current), Some(start_pt)) = (prev_point, subpath_start) {
456                        if (current.x - start_pt.x).abs() > AXIS_TOLERANCE
457                            || (current.y - start_pt.y).abs() > AXIS_TOLERANCE
458                        {
459                            push_line(current, start_pt, painted, page_height, lines);
460                        }
461                    }
462                }
463                prev_point = subpath_start;
464            }
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::geometry::Ctm;
473    use crate::painting::{DashPattern, FillRule, GraphicsState};
474    use crate::path::PathBuilder;
475
476    const PAGE_HEIGHT: f64 = 792.0;
477
478    // --- Direct construction and field access tests ---
479
480    #[test]
481    fn test_line_construction_and_field_access() {
482        let line = Line {
483            x0: 10.0,
484            top: 20.0,
485            x1: 100.0,
486            bottom: 20.0,
487            line_width: 1.5,
488            stroke_color: Color::Rgb(1.0, 0.0, 0.0),
489            orientation: Orientation::Horizontal,
490        };
491        assert_eq!(line.x0, 10.0);
492        assert_eq!(line.top, 20.0);
493        assert_eq!(line.x1, 100.0);
494        assert_eq!(line.bottom, 20.0);
495        assert_eq!(line.line_width, 1.5);
496        assert_eq!(line.stroke_color, Color::Rgb(1.0, 0.0, 0.0));
497        assert_eq!(line.orientation, Orientation::Horizontal);
498    }
499
500    #[test]
501    fn test_rect_construction_and_field_access() {
502        let rect = Rect {
503            x0: 50.0,
504            top: 100.0,
505            x1: 200.0,
506            bottom: 300.0,
507            line_width: 2.0,
508            stroke: true,
509            fill: true,
510            stroke_color: Color::Gray(0.0),
511            fill_color: Color::Cmyk(0.0, 1.0, 1.0, 0.0),
512        };
513        assert_eq!(rect.x0, 50.0);
514        assert_eq!(rect.top, 100.0);
515        assert_eq!(rect.x1, 200.0);
516        assert_eq!(rect.bottom, 300.0);
517        assert_eq!(rect.line_width, 2.0);
518        assert!(rect.stroke);
519        assert!(rect.fill);
520        assert_eq!(rect.stroke_color, Color::Gray(0.0));
521        assert_eq!(rect.fill_color, Color::Cmyk(0.0, 1.0, 1.0, 0.0));
522        assert_eq!(rect.width(), 150.0);
523        assert_eq!(rect.height(), 200.0);
524    }
525
526    #[test]
527    fn test_curve_construction_and_field_access() {
528        let curve = Curve {
529            x0: 0.0,
530            top: 50.0,
531            x1: 100.0,
532            bottom: 100.0,
533            pts: vec![(0.0, 100.0), (30.0, 50.0), (70.0, 50.0), (100.0, 100.0)],
534            line_width: 1.0,
535            stroke: true,
536            fill: false,
537            stroke_color: Color::black(),
538            fill_color: Color::black(),
539        };
540        assert_eq!(curve.x0, 0.0);
541        assert_eq!(curve.top, 50.0);
542        assert_eq!(curve.x1, 100.0);
543        assert_eq!(curve.bottom, 100.0);
544        assert_eq!(curve.pts.len(), 4);
545        assert_eq!(curve.pts[0], (0.0, 100.0));
546        assert_eq!(curve.pts[3], (100.0, 100.0));
547        assert_eq!(curve.line_width, 1.0);
548        assert!(curve.stroke);
549        assert!(!curve.fill);
550    }
551
552    fn default_gs() -> GraphicsState {
553        GraphicsState::default()
554    }
555
556    fn custom_gs() -> GraphicsState {
557        GraphicsState {
558            line_width: 2.5,
559            stroke_color: Color::Rgb(1.0, 0.0, 0.0),
560            fill_color: Color::Rgb(0.0, 0.0, 1.0),
561            ..GraphicsState::default()
562        }
563    }
564
565    fn assert_approx(a: f64, b: f64) {
566        assert!(
567            (a - b).abs() < 1e-6,
568            "expected {b}, got {a}, diff={}",
569            (a - b).abs()
570        );
571    }
572
573    // --- Horizontal line ---
574
575    #[test]
576    fn test_horizontal_line_extraction() {
577        let mut builder = PathBuilder::new(Ctm::identity());
578        builder.move_to(100.0, 500.0);
579        builder.line_to(300.0, 500.0);
580        let painted = builder.stroke(&default_gs());
581
582        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
583        assert_eq!(lines.len(), 1);
584        assert!(rects.is_empty());
585
586        let line = &lines[0];
587        assert_approx(line.x0, 100.0);
588        assert_approx(line.x1, 300.0);
589        // y-flip: 792 - 500 = 292
590        assert_approx(line.top, 292.0);
591        assert_approx(line.bottom, 292.0);
592        assert_eq!(line.orientation, Orientation::Horizontal);
593        assert_approx(line.line_width, 1.0);
594    }
595
596    // --- Vertical line ---
597
598    #[test]
599    fn test_vertical_line_extraction() {
600        let mut builder = PathBuilder::new(Ctm::identity());
601        builder.move_to(200.0, 100.0);
602        builder.line_to(200.0, 400.0);
603        let painted = builder.stroke(&default_gs());
604
605        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
606        assert_eq!(lines.len(), 1);
607        assert!(rects.is_empty());
608
609        let line = &lines[0];
610        assert_approx(line.x0, 200.0);
611        assert_approx(line.x1, 200.0);
612        // y-flip: 792-400=392 (top), 792-100=692 (bottom)
613        assert_approx(line.top, 392.0);
614        assert_approx(line.bottom, 692.0);
615        assert_eq!(line.orientation, Orientation::Vertical);
616    }
617
618    // --- Diagonal line ---
619
620    #[test]
621    fn test_diagonal_line_extraction() {
622        let mut builder = PathBuilder::new(Ctm::identity());
623        builder.move_to(100.0, 100.0);
624        builder.line_to(300.0, 400.0);
625        let painted = builder.stroke(&default_gs());
626
627        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
628        assert_eq!(lines.len(), 1);
629        assert!(rects.is_empty());
630
631        let line = &lines[0];
632        assert_approx(line.x0, 100.0);
633        assert_approx(line.x1, 300.0);
634        // y-flip: min(792-100, 792-400) = min(692, 392) = 392
635        assert_approx(line.top, 392.0);
636        assert_approx(line.bottom, 692.0);
637        assert_eq!(line.orientation, Orientation::Diagonal);
638    }
639
640    // --- Line with custom width and color ---
641
642    #[test]
643    fn test_line_with_custom_width_and_color() {
644        let mut builder = PathBuilder::new(Ctm::identity());
645        builder.move_to(0.0, 0.0);
646        builder.line_to(100.0, 0.0);
647        let painted = builder.stroke(&custom_gs());
648
649        let (lines, _, _) = extract_shapes(&painted, PAGE_HEIGHT);
650        assert_eq!(lines.len(), 1);
651
652        let line = &lines[0];
653        assert_approx(line.line_width, 2.5);
654        assert_eq!(line.stroke_color, Color::Rgb(1.0, 0.0, 0.0));
655    }
656
657    // --- Rectangle from `re` operator ---
658
659    #[test]
660    fn test_rect_from_re_operator() {
661        let mut builder = PathBuilder::new(Ctm::identity());
662        // re(x, y, w, h) in PDF coordinates (bottom-left origin)
663        builder.rectangle(100.0, 200.0, 200.0, 100.0);
664        let painted = builder.stroke(&default_gs());
665
666        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
667        assert!(lines.is_empty());
668        assert_eq!(rects.len(), 1);
669
670        let rect = &rects[0];
671        assert_approx(rect.x0, 100.0);
672        assert_approx(rect.x1, 300.0);
673        // y-flip: min(792-200, 792-300) = min(592, 492) = 492
674        assert_approx(rect.top, 492.0);
675        // max(792-200, 792-300) = 592
676        assert_approx(rect.bottom, 592.0);
677        assert!(rect.stroke);
678        assert!(!rect.fill);
679    }
680
681    // --- Rectangle from 4-line closed path ---
682
683    #[test]
684    fn test_rect_from_four_line_closed_path() {
685        let mut builder = PathBuilder::new(Ctm::identity());
686        // Manually construct a rectangle without using `re`
687        builder.move_to(50.0, 100.0);
688        builder.line_to(250.0, 100.0);
689        builder.line_to(250.0, 300.0);
690        builder.line_to(50.0, 300.0);
691        builder.close_path();
692        let painted = builder.fill(&default_gs());
693
694        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
695        assert!(lines.is_empty());
696        assert_eq!(rects.len(), 1);
697
698        let rect = &rects[0];
699        assert_approx(rect.x0, 50.0);
700        assert_approx(rect.x1, 250.0);
701        // y-flip: min(792-100, 792-300) = min(692, 492) = 492
702        assert_approx(rect.top, 492.0);
703        assert_approx(rect.bottom, 692.0);
704        assert!(!rect.stroke);
705        assert!(rect.fill);
706    }
707
708    // --- Fill+stroke rectangle ---
709
710    #[test]
711    fn test_rect_fill_and_stroke() {
712        let mut builder = PathBuilder::new(Ctm::identity());
713        builder.rectangle(10.0, 20.0, 100.0, 50.0);
714        let painted = builder.fill_and_stroke(&custom_gs());
715
716        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
717        assert!(lines.is_empty());
718        assert_eq!(rects.len(), 1);
719
720        let rect = &rects[0];
721        assert!(rect.stroke);
722        assert!(rect.fill);
723        assert_approx(rect.line_width, 2.5);
724        assert_eq!(rect.stroke_color, Color::Rgb(1.0, 0.0, 0.0));
725        assert_eq!(rect.fill_color, Color::Rgb(0.0, 0.0, 1.0));
726    }
727
728    // --- Rect dimensions ---
729
730    #[test]
731    fn test_rect_width_and_height() {
732        let mut builder = PathBuilder::new(Ctm::identity());
733        builder.rectangle(100.0, 200.0, 150.0, 80.0);
734        let painted = builder.stroke(&default_gs());
735
736        let (_, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
737        assert_eq!(rects.len(), 1);
738
739        let rect = &rects[0];
740        assert_approx(rect.width(), 150.0);
741        assert_approx(rect.height(), 80.0);
742    }
743
744    // --- Non-rectangular closed path produces lines ---
745
746    #[test]
747    fn test_non_rect_closed_path_produces_lines() {
748        // A triangle (3 vertices, not 4) — not a rectangle
749        let mut builder = PathBuilder::new(Ctm::identity());
750        builder.move_to(100.0, 100.0);
751        builder.line_to(200.0, 100.0);
752        builder.line_to(150.0, 200.0);
753        builder.close_path(); // closes back to (100, 100)
754        let painted = builder.stroke(&default_gs());
755
756        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
757        assert!(rects.is_empty());
758        // 3 lines: (100,100)→(200,100), (200,100)→(150,200), (150,200)→(100,100)
759        assert_eq!(lines.len(), 3);
760
761        // First line is horizontal
762        assert_eq!(lines[0].orientation, Orientation::Horizontal);
763        // Other two are diagonal
764        assert_eq!(lines[1].orientation, Orientation::Diagonal);
765        assert_eq!(lines[2].orientation, Orientation::Diagonal);
766    }
767
768    // --- Non-axis-aligned 4-vertex path produces lines ---
769
770    #[test]
771    fn test_non_axis_aligned_quadrilateral_produces_lines() {
772        // A diamond/rhombus shape — 4 vertices but not axis-aligned
773        let mut builder = PathBuilder::new(Ctm::identity());
774        builder.move_to(150.0, 100.0);
775        builder.line_to(200.0, 200.0);
776        builder.line_to(150.0, 300.0);
777        builder.line_to(100.0, 200.0);
778        builder.close_path();
779        let painted = builder.stroke(&default_gs());
780
781        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
782        assert!(rects.is_empty());
783        assert_eq!(lines.len(), 4); // 4 diagonal lines
784    }
785
786    // --- Fill-only path does not produce lines ---
787
788    #[test]
789    fn test_fill_only_does_not_produce_lines() {
790        // A non-rectangle filled path should not produce lines
791        let mut builder = PathBuilder::new(Ctm::identity());
792        builder.move_to(100.0, 100.0);
793        builder.line_to(200.0, 100.0);
794        builder.line_to(150.0, 200.0);
795        builder.close_path();
796        let painted = builder.fill(&default_gs());
797
798        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
799        assert!(lines.is_empty()); // fill-only, no stroked lines
800        assert!(rects.is_empty()); // not a rectangle
801    }
802
803    // --- Multiple subpaths ---
804
805    #[test]
806    fn test_multiple_subpaths_lines() {
807        let mut builder = PathBuilder::new(Ctm::identity());
808        // First subpath: horizontal line
809        builder.move_to(0.0, 100.0);
810        builder.line_to(200.0, 100.0);
811        // Second subpath: vertical line
812        builder.move_to(100.0, 0.0);
813        builder.line_to(100.0, 200.0);
814        let painted = builder.stroke(&default_gs());
815
816        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
817        assert_eq!(lines.len(), 2);
818        assert!(rects.is_empty());
819        assert_eq!(lines[0].orientation, Orientation::Horizontal);
820        assert_eq!(lines[1].orientation, Orientation::Vertical);
821    }
822
823    // --- Multiple subpaths: rect + line ---
824
825    #[test]
826    fn test_multiple_subpaths_rect_and_line() {
827        let mut builder = PathBuilder::new(Ctm::identity());
828        // First subpath: rectangle
829        builder.rectangle(10.0, 10.0, 100.0, 50.0);
830        // Second subpath: a line
831        builder.move_to(0.0, 100.0);
832        builder.line_to(200.0, 100.0);
833        let painted = builder.stroke(&default_gs());
834
835        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
836        assert_eq!(rects.len(), 1);
837        assert_eq!(lines.len(), 1);
838    }
839
840    // --- n (end path, no painting) produces nothing ---
841
842    #[test]
843    fn test_end_path_produces_nothing() {
844        let mut builder = PathBuilder::new(Ctm::identity());
845        builder.rectangle(10.0, 10.0, 100.0, 50.0);
846        let result = builder.end_path();
847        assert!(result.is_none());
848        // No painted path means nothing to extract
849    }
850
851    // --- Orientation classification ---
852
853    #[test]
854    fn test_classify_orientation_horizontal() {
855        assert_eq!(
856            classify_orientation(0.0, 100.0, 200.0, 100.0),
857            Orientation::Horizontal
858        );
859    }
860
861    #[test]
862    fn test_classify_orientation_vertical() {
863        assert_eq!(
864            classify_orientation(100.0, 0.0, 100.0, 200.0),
865            Orientation::Vertical
866        );
867    }
868
869    #[test]
870    fn test_classify_orientation_diagonal() {
871        assert_eq!(
872            classify_orientation(0.0, 0.0, 100.0, 200.0),
873            Orientation::Diagonal
874        );
875    }
876
877    // --- Y-flip ---
878
879    #[test]
880    fn test_y_flip() {
881        assert_approx(flip_y(0.0, 792.0), 792.0);
882        assert_approx(flip_y(792.0, 792.0), 0.0);
883        assert_approx(flip_y(396.0, 792.0), 396.0);
884        assert_approx(flip_y(100.0, 792.0), 692.0);
885    }
886
887    // --- Edge case: empty path ---
888
889    #[test]
890    fn test_empty_path_produces_nothing() {
891        let painted = PaintedPath {
892            path: crate::path::Path {
893                segments: Vec::new(),
894            },
895            stroke: true,
896            fill: false,
897            fill_rule: FillRule::NonZeroWinding,
898            line_width: 1.0,
899            stroke_color: Color::black(),
900            fill_color: Color::black(),
901            dash_pattern: DashPattern::solid(),
902            stroke_alpha: 1.0,
903            fill_alpha: 1.0,
904        };
905
906        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
907        assert!(lines.is_empty());
908        assert!(rects.is_empty());
909    }
910
911    // --- Edge case: single MoveTo ---
912
913    #[test]
914    fn test_single_moveto_produces_nothing() {
915        let mut builder = PathBuilder::new(Ctm::identity());
916        builder.move_to(100.0, 100.0);
917        let painted = builder.stroke(&default_gs());
918
919        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
920        assert!(lines.is_empty());
921        assert!(rects.is_empty());
922    }
923
924    // --- Path with curves produces curves, not rects ---
925
926    #[test]
927    fn test_path_with_curves_no_rect_detection() {
928        let mut builder = PathBuilder::new(Ctm::identity());
929        builder.move_to(0.0, 0.0);
930        builder.curve_to(10.0, 50.0, 90.0, 50.0, 100.0, 0.0);
931        builder.close_path();
932        let painted = builder.stroke(&default_gs());
933
934        let (lines, rects, curves) = extract_shapes(&painted, PAGE_HEIGHT);
935        assert!(rects.is_empty());
936        // ClosePath generates a line back to (0,0) since path is stroked
937        assert_eq!(lines.len(), 1);
938        assert_eq!(curves.len(), 1);
939    }
940
941    // --- Rectangle with CTM transformation ---
942
943    #[test]
944    fn test_rect_with_ctm_scale() {
945        // CTM scales by 2x
946        let ctm = Ctm::new(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
947        let mut builder = PathBuilder::new(ctm);
948        builder.rectangle(50.0, 100.0, 100.0, 50.0);
949        let painted = builder.stroke(&default_gs());
950
951        let (lines, rects, _) = extract_shapes(&painted, PAGE_HEIGHT);
952        assert!(lines.is_empty());
953        assert_eq!(rects.len(), 1);
954
955        let rect = &rects[0];
956        // Scaled: x: 100..300, y: 200..300
957        assert_approx(rect.x0, 100.0);
958        assert_approx(rect.x1, 300.0);
959        // y-flip: 792-300=492 (top), 792-200=592 (bottom)
960        assert_approx(rect.top, 492.0);
961        assert_approx(rect.bottom, 592.0);
962    }
963
964    // ==================== Curve extraction tests (US-024) ====================
965
966    #[test]
967    fn test_curve_extraction_simple() {
968        // Simple cubic Bezier from (0,0) to (100,0) with control points at (10,50) and (90,50)
969        let mut builder = PathBuilder::new(Ctm::identity());
970        builder.move_to(0.0, 0.0);
971        builder.curve_to(10.0, 50.0, 90.0, 50.0, 100.0, 0.0);
972        let painted = builder.stroke(&default_gs());
973
974        let (_, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
975        assert_eq!(curves.len(), 1);
976
977        let curve = &curves[0];
978        // 4 points: start, cp1, cp2, end
979        assert_eq!(curve.pts.len(), 4);
980        // Start: (0, flip(0)) = (0, 792)
981        assert_approx(curve.pts[0].0, 0.0);
982        assert_approx(curve.pts[0].1, 792.0);
983        // CP1: (10, flip(50)) = (10, 742)
984        assert_approx(curve.pts[1].0, 10.0);
985        assert_approx(curve.pts[1].1, 742.0);
986        // CP2: (90, flip(50)) = (90, 742)
987        assert_approx(curve.pts[2].0, 90.0);
988        assert_approx(curve.pts[2].1, 742.0);
989        // End: (100, flip(0)) = (100, 792)
990        assert_approx(curve.pts[3].0, 100.0);
991        assert_approx(curve.pts[3].1, 792.0);
992    }
993
994    #[test]
995    fn test_curve_bbox() {
996        let mut builder = PathBuilder::new(Ctm::identity());
997        builder.move_to(0.0, 0.0);
998        builder.curve_to(10.0, 50.0, 90.0, 50.0, 100.0, 0.0);
999        let painted = builder.stroke(&default_gs());
1000
1001        let (_, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1002        let curve = &curves[0];
1003
1004        // x: min(0, 10, 90, 100) = 0, max = 100
1005        assert_approx(curve.x0, 0.0);
1006        assert_approx(curve.x1, 100.0);
1007        // y (flipped): min(792, 742, 742, 792) = 742, max = 792
1008        assert_approx(curve.top, 742.0);
1009        assert_approx(curve.bottom, 792.0);
1010    }
1011
1012    #[test]
1013    fn test_curve_captures_graphics_state() {
1014        let mut builder = PathBuilder::new(Ctm::identity());
1015        builder.move_to(0.0, 0.0);
1016        builder.curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 0.0);
1017        let painted = builder.stroke(&custom_gs());
1018
1019        let (_, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1020        assert_eq!(curves.len(), 1);
1021
1022        let curve = &curves[0];
1023        assert_approx(curve.line_width, 2.5);
1024        assert!(curve.stroke);
1025        assert!(!curve.fill);
1026        assert_eq!(curve.stroke_color, Color::Rgb(1.0, 0.0, 0.0));
1027    }
1028
1029    #[test]
1030    fn test_curve_fill_only() {
1031        let mut builder = PathBuilder::new(Ctm::identity());
1032        builder.move_to(0.0, 0.0);
1033        builder.curve_to(10.0, 50.0, 90.0, 50.0, 100.0, 0.0);
1034        builder.close_path();
1035        let painted = builder.fill(&default_gs());
1036
1037        let (lines, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1038        assert_eq!(curves.len(), 1);
1039        assert!(curves[0].fill);
1040        assert!(!curves[0].stroke);
1041        // Fill-only: no lines from ClosePath
1042        assert!(lines.is_empty());
1043    }
1044
1045    #[test]
1046    fn test_multiple_curves_in_subpath() {
1047        // Two curve segments in one subpath
1048        let mut builder = PathBuilder::new(Ctm::identity());
1049        builder.move_to(0.0, 0.0);
1050        builder.curve_to(10.0, 50.0, 40.0, 50.0, 50.0, 0.0);
1051        builder.curve_to(60.0, 50.0, 90.0, 50.0, 100.0, 0.0);
1052        let painted = builder.stroke(&default_gs());
1053
1054        let (_, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1055        assert_eq!(curves.len(), 2);
1056
1057        // First curve: (0,0) -> (50,0)
1058        assert_approx(curves[0].pts[0].0, 0.0);
1059        assert_approx(curves[0].pts[3].0, 50.0);
1060        // Second curve: (50,0) -> (100,0)
1061        assert_approx(curves[1].pts[0].0, 50.0);
1062        assert_approx(curves[1].pts[3].0, 100.0);
1063    }
1064
1065    #[test]
1066    fn test_mixed_line_and_curve_subpath() {
1067        // Subpath with both LineTo and CurveTo: line + curve + line
1068        let mut builder = PathBuilder::new(Ctm::identity());
1069        builder.move_to(0.0, 0.0);
1070        builder.line_to(50.0, 0.0);
1071        builder.curve_to(60.0, 0.0, 70.0, 10.0, 70.0, 20.0);
1072        builder.line_to(70.0, 50.0);
1073        let painted = builder.stroke(&default_gs());
1074
1075        let (lines, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1076        assert_eq!(curves.len(), 1);
1077        assert_eq!(lines.len(), 2); // line_to(50,0) and line_to(70,50)
1078    }
1079
1080    #[test]
1081    fn test_curve_with_ctm_transform() {
1082        // CTM scales by 2x
1083        let ctm = Ctm::new(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
1084        let mut builder = PathBuilder::new(ctm);
1085        builder.move_to(0.0, 0.0);
1086        builder.curve_to(10.0, 25.0, 40.0, 25.0, 50.0, 0.0);
1087        let painted = builder.stroke(&default_gs());
1088
1089        let (_, _, curves) = extract_shapes(&painted, PAGE_HEIGHT);
1090        assert_eq!(curves.len(), 1);
1091
1092        let curve = &curves[0];
1093        // Coords are CTM-transformed: (0,0)->(0,0), (10,25)->(20,50), (40,25)->(80,50), (50,0)->(100,0)
1094        assert_approx(curve.pts[0].0, 0.0);
1095        assert_approx(curve.pts[1].0, 20.0);
1096        assert_approx(curve.pts[2].0, 80.0);
1097        assert_approx(curve.pts[3].0, 100.0);
1098    }
1099}