Skip to main content

xberg_native_pdf/elements/
path.rs

1//! Path/vector graphics content element types.
2//!
3//! This module provides the `PathContent` type for representing
4//! vector graphics in PDFs.
5
6use crate::extractors::text::ArtifactType;
7use crate::geometry::Rect;
8use crate::layout::Color;
9
10/// Vector path content that can be extracted from or written to a PDF.
11///
12/// This represents vector graphics such as lines, curves, and shapes.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct PathContent {
15    /// Bounding box of the path
16    pub bbox: Rect,
17    /// Path operations
18    pub operations: Vec<PathOperation>,
19    /// Stroke color (None for no stroke)
20    pub stroke_color: Option<Color>,
21    /// Fill color (None for no fill)
22    pub fill_color: Option<Color>,
23    /// Stroke width in points
24    pub stroke_width: f32,
25    /// Line cap style
26    pub line_cap: LineCap,
27    /// Line join style
28    pub line_join: LineJoin,
29    /// Optional dash pattern: `Some((dashes, phase))` emits a
30    /// `[dashes...] phase d` operator before stroking. `dashes` is on/
31    /// off lengths in points (e.g. `[3.0, 2.0]` = dash 3 pt, gap 2 pt,
32    /// repeating); `phase` is the starting offset. `None` leaves the
33    /// line solid.
34    #[serde(default)]
35    pub dash_pattern: Option<(Vec<f32>, f32)>,
36    /// Optional 2D affine transform in PDF row order `[a b c d e f]`.
37    /// When set, the path is wrapped in `q ... cm ... Q` on emission
38    /// so graphics-state stays scoped. Populated by
39    /// `FluentPageBuilder::{rotated, scaled, translated, with_transform}`
40    /// closures — v0.3.39 (text-only) extended here to cover paths.
41    /// #393 Bundle A-2 follow-up.
42    #[serde(default)]
43    pub matrix: Option<[f32; 6]>,
44    /// Reading order index
45    pub reading_order: Option<usize>,
46    /// When set, this path is wrapped in `/Artifact <</Type /T>>  BDC … EMC`
47    /// markers so accessibility tools can skip it. Useful for decorative
48    /// separator lines (footnote rules, header/footer rules).
49    #[serde(skip)]
50    pub artifact_type: Option<ArtifactType>,
51    /// Optional Content Group (PDF "layer") name resolved from the
52    /// surrounding `BDC /OC … EMC` markers in the content stream. Set
53    /// during path extraction by `PathExtractor` when an OCG is active;
54    /// `None` for paths emitted outside any `/OC`-tagged marked-content
55    /// region or in PDFs that do not declare optional content.
56    ///
57    /// The string is the human-readable `/Name` entry of the referenced
58    /// `OptionalContentGroup`, e.g. `"A-GRID"`, `"S-COLS"`, `"A-WALL-DIM"`
59    /// for PDFs exported from Revit/AutoCAD with layer metadata intact.
60    /// Reference: ISO 32000-1:2008 §8.11 (Optional Content) + §14.6
61    /// (Marked Content).
62    #[serde(default)]
63    pub layer: Option<String>,
64}
65
66impl PathContent {
67    /// Create a new empty path content element.
68    pub fn new(bbox: Rect) -> Self {
69        Self {
70            bbox,
71            operations: Vec::new(),
72            stroke_color: Some(Color::black()),
73            fill_color: None,
74            stroke_width: 1.0,
75            line_cap: LineCap::Butt,
76            line_join: LineJoin::Miter,
77            dash_pattern: None,
78            matrix: None,
79            reading_order: None,
80            artifact_type: None,
81            layer: None,
82        }
83    }
84
85    /// Create a path from operations.
86    pub fn from_operations(operations: Vec<PathOperation>) -> Self {
87        let bbox = path_operations_bbox(&operations);
88        Self {
89            bbox,
90            operations,
91            stroke_color: Some(Color::black()),
92            fill_color: None,
93            stroke_width: 1.0,
94            line_cap: LineCap::Butt,
95            line_join: LineJoin::Miter,
96            dash_pattern: None,
97            matrix: None,
98            reading_order: None,
99            artifact_type: None,
100            layer: None,
101        }
102    }
103
104    /// Set stroke color.
105    pub fn with_stroke(mut self, color: Color) -> Self {
106        self.stroke_color = Some(color);
107        self
108    }
109
110    /// Set fill color.
111    pub fn with_fill(mut self, color: Color) -> Self {
112        self.fill_color = Some(color);
113        self
114    }
115
116    /// Set stroke width.
117    pub fn with_stroke_width(mut self, width: f32) -> Self {
118        self.stroke_width = width;
119        self
120    }
121
122    /// Set reading order.
123    pub fn with_reading_order(mut self, order: usize) -> Self {
124        self.reading_order = Some(order);
125        self
126    }
127
128    /// Set the Optional Content Group (PDF "layer") name. Used by
129    /// `PathExtractor` while walking the content stream to attach the
130    /// active OCG name to each extracted path.
131    pub fn with_layer(mut self, layer: impl Into<String>) -> Self {
132        self.layer = Some(layer.into());
133        self
134    }
135
136    /// Add a path operation.
137    pub fn push(&mut self, op: PathOperation) {
138        self.operations.push(op);
139    }
140
141    /// Check if this path has a stroke.
142    pub fn has_stroke(&self) -> bool {
143        self.stroke_color.is_some() && self.stroke_width > 0.0
144    }
145
146    /// Check if this path has a fill.
147    pub fn has_fill(&self) -> bool {
148        self.fill_color.is_some()
149    }
150
151    /// Check if this path represents a single straight line (v0.3.14).
152    ///
153    /// A path is a straight line if it has exactly 2 operations:
154    /// MoveTo followed by LineTo.
155    pub fn is_straight_line(&self) -> bool {
156        (self.operations.len() == 2
157            && matches!(self.operations[0], PathOperation::MoveTo(_, _))
158            && matches!(self.operations[1], PathOperation::LineTo(_, _)))
159            || (self.operations.len() == 3
160                && matches!(self.operations[0], PathOperation::MoveTo(_, _))
161                && matches!(self.operations[1], PathOperation::LineTo(_, _))
162                && matches!(self.operations[2], PathOperation::ClosePath))
163    }
164
165    /// Rendered extents of this path: the geometric [`bbox`](Self::bbox)
166    /// inflated by the stroke (ISO 32000-1:2008 §8.4.3.2 — half the line
167    /// width straddles each side of the path).
168    ///
169    /// Print-era producers draw a table's vertical rule as a ~1 pt
170    /// horizontal segment stroked as wide as the table is tall, so the
171    /// geometric bbox (1×0 pt) bears no resemblance to the bar the reader
172    /// sees. Classifiers that reason about what is *rendered*
173    /// (line orientation, table primitives, region hit-testing) should use
174    /// this; consumers that need the raw path geometry (corner tests, SVG
175    /// path data, editing) keep `bbox`.
176    ///
177    /// For a single straight segment the inflation is exact: half the
178    /// stroke width perpendicular to the segment, plus the cap extension
179    /// along it (`Butt` caps add nothing axially; `Round`/`Square` add half
180    /// the width per end, §8.4.3.3). For any other shape the bbox is
181    /// inflated by half the width on all four sides — a conservative
182    /// superset that is exact for axis-aligned stroked outlines.
183    ///
184    /// Unstroked paths return `bbox` unchanged.
185    pub fn rendered_bbox(&self) -> Rect {
186        if !self.has_stroke() {
187            return self.bbox;
188        }
189        let half = self.stroke_width * 0.5;
190
191        if let [PathOperation::MoveTo(x0, y0), PathOperation::LineTo(x1, y1), ..] = self.operations.as_slice()
192            && self.is_straight_line()
193        {
194            let (dx, dy) = (x1 - x0, y1 - y0);
195            let len = dx.hypot(dy);
196            // A zero-length butt-capped segment paints nothing
197            // (§8.4.3.3), so its rendered extent is its degenerate
198            // geometry; Round/Square caps paint a dot and fall through
199            // to the all-sides outset below. ~keep
200            if len <= f32::EPSILON && self.line_cap == LineCap::Butt {
201                return self.bbox;
202            }
203            if len > f32::EPSILON {
204                // Perpendicular unit vector scaled to the half-width,
205                // plus the axial cap extension. ~keep
206                let (px, py) = ((dy / len * half).abs(), (dx / len * half).abs());
207                let (cx, cy) = if self.line_cap == LineCap::Butt {
208                    (0.0, 0.0)
209                } else {
210                    ((dx / len * half).abs(), (dy / len * half).abs())
211                };
212                return Rect::new(
213                    self.bbox.x - px - cx,
214                    self.bbox.y - py - cy,
215                    self.bbox.width + 2.0 * (px + cx),
216                    self.bbox.height + 2.0 * (py + cy),
217                );
218            }
219        }
220
221        Rect::new(
222            self.bbox.x - half,
223            self.bbox.y - half,
224            self.bbox.width + 2.0 * half,
225            self.bbox.height + 2.0 * half,
226        )
227    }
228
229    /// Check if this path is a horizontal line within a tolerance (v0.3.16).
230    ///
231    /// `tolerance` bounds the *geometric* minor extent as before (a nearly
232    /// axis-aligned segment or sliver rectangle). Orientation is then
233    /// confirmed on the [`rendered_bbox`](Self::rendered_bbox): a 1 pt
234    /// segment stroked 430 pt wide renders as a vertical bar and must not
235    /// classify as a horizontal line just because its raw geometry is a
236    /// horizontal speck.
237    pub fn is_horizontal_line(&self, tolerance: f32) -> bool {
238        if !self.is_straight_line() && !self.is_rectangle() {
239            return false;
240        }
241        let rendered = self.rendered_bbox();
242        self.bbox.height.abs() < tolerance && rendered.width.abs() >= rendered.height.abs()
243    }
244
245    /// Check if this path is a vertical line within a tolerance (v0.3.16).
246    ///
247    /// Same contract as [`is_horizontal_line`](Self::is_horizontal_line):
248    /// geometric thinness within `tolerance`, orientation confirmed on the
249    /// rendered extents.
250    pub fn is_vertical_line(&self, tolerance: f32) -> bool {
251        if !self.is_straight_line() && !self.is_rectangle() {
252            return false;
253        }
254        let rendered = self.rendered_bbox();
255        self.bbox.width.abs() < tolerance && rendered.height.abs() >= rendered.width.abs()
256    }
257
258    /// Check if this path's bounding box is nearly touching another (v0.3.16).
259    pub fn is_nearly_touching(&self, other: &Rect, tolerance: f32) -> bool {
260        let expanded = Rect::new(
261            self.bbox.x - tolerance,
262            self.bbox.y - tolerance,
263            self.bbox.width + 2.0 * tolerance,
264            self.bbox.height + 2.0 * tolerance,
265        );
266        expanded.intersects(other)
267    }
268
269    /// Check if this path represents a single rectangle (v0.3.14).
270    ///
271    /// A path is a rectangle if it has exactly 1 operation: Rectangle,
272    /// or if it has 5 operations: MoveTo, 3x LineTo, ClosePath that form a rectangle.
273    pub fn is_rectangle(&self) -> bool {
274        if self.operations.len() == 1 && matches!(self.operations[0], PathOperation::Rectangle(_, _, _, _)) {
275            return true;
276        }
277
278        // Case 2: MoveTo + 3x LineTo + (Optional ClosePath)
279        // Must be axis-aligned. We check that consecutive points share X or Y. ~keep
280        if ((self.operations.len() == 5 && matches!(self.operations[4], PathOperation::ClosePath))
281            || (self.operations.len() == 4))
282            && let (
283                PathOperation::MoveTo(x0, y0),
284                PathOperation::LineTo(x1, y1),
285                PathOperation::LineTo(x2, y2),
286                PathOperation::LineTo(x3, y3),
287            ) = (
288                &self.operations[0],
289                &self.operations[1],
290                &self.operations[2],
291                &self.operations[3],
292            )
293        {
294            let tol = 0.1;
295            let side1 = ((x0 - x1).abs() < tol) || ((y0 - y1).abs() < tol);
296            let side2 = ((x1 - x2).abs() < tol) || ((y1 - y2).abs() < tol);
297            let side3 = ((x2 - x3).abs() < tol) || ((y2 - y3).abs() < tol);
298
299            return side1 && side2 && side3;
300        }
301
302        false
303    }
304
305    /// Check if this path is "box-like" or "line-like" based on its dimensions (v0.3.16).
306    /// This is a fuzzy heuristic for table detection.
307    ///
308    /// Line length is judged on the [`rendered_bbox`](Self::rendered_bbox)
309    /// while thinness stays geometric, so a table rule encoded as a short
310    /// segment with a table-height stroke width — a 1×0 pt geometric speck
311    /// rendering as a 1×430 pt bar — is recognized as the ruling it draws
312    ///, and an ordinary rule with a thick stroke keeps
313    /// classifying as a line exactly as before.
314    ///
315    /// Thresholds (inherited from the v0.3.16 heuristic): a ruling must
316    /// run more than 5 pt — half an em at 10 pt body text — so tick marks
317    /// and list bullets don't qualify; it must be under 2 pt across, twice
318    /// the heaviest common ruling weight (typical PDF rules are 0.5–1 pt)
319    /// while safely below any filled cell; and a box must stay under
320    /// 1000 pt per side so absurdly large shapes (chart backgrounds, full
321    /// artboards) don't seed a cluster on their own.
322    ///
323    /// GH#1656: `1000 pt` does **not** exclude an ordinary page — A4
324    /// (595×842) and US-Letter (612×792) both pass this bound, so a
325    /// full-page background rectangle (common Office-to-PDF output) is
326    /// accepted here as a table primitive and can cluster with every
327    /// other primitive on the page. This function has no access to the
328    /// page's MediaBox (`PathContent` carries no page dimensions), so it
329    /// cannot make that call itself. Page-relative filtering of such
330    /// furniture happens one level up, in `PdfDocument::extract_tables_with_config`
331    /// and `PdfDocument::extract_page_tables` (`document/tables.rs`), which use
332    /// [`PathContent::is_page_frame_rectangle`](Self::is_page_frame_rectangle)
333    /// against the real MediaBox before primitives reach the clusterer in
334    /// `spatial_table_detector.rs`. ~keep
335    pub fn is_table_primitive(&self) -> bool {
336        let rendered = self.rendered_bbox();
337
338        // Very thin horizontal or vertical line: extends > 5.0 along its
339        // rendered axis, geometrically thinner than 2.0 across it, and
340        // rendered at least 2:1 elongated — a zero-length segment with a
341        // fat round/square cap (dot leaders, bullets) renders as a square
342        // blob, not a ruling, and must not seed line clusters. ~keep
343        if (rendered.width.abs() > 5.0
344            && self.bbox.height.abs() < 2.0
345            && rendered.width.abs() >= 2.0 * rendered.height.abs())
346            || (rendered.height.abs() > 5.0
347                && self.bbox.width.abs() < 2.0
348                && rendered.height.abs() >= 2.0 * rendered.width.abs())
349        {
350            return true;
351        }
352
353        let w = self.bbox.width.abs();
354        let h = self.bbox.height.abs();
355        if w > 5.0 && h > 5.0 && w < 1000.0 && h < 1000.0 {
356            return true;
357        }
358
359        false
360    }
361
362    /// True when this path is a rectangle covering at least this fraction of
363    /// the MediaBox in *both* dimensions. 0.9 leaves generous margin for a
364    /// page background that stops just short of the trim box while still
365    /// rejecting anything smaller than page furniture — a genuine table
366    /// spanning most of a page's text column is still well under 90% of the
367    /// full MediaBox once margins are accounted for. GH#1656. ~keep
368    pub const PAGE_FRAME_COVERAGE_THRESHOLD: f32 = 0.9;
369
370    /// True when this path is a rectangle spanning at least
371    /// [`PAGE_FRAME_COVERAGE_THRESHOLD`](Self::PAGE_FRAME_COVERAGE_THRESHOLD)
372    /// of `media_box` in both width and height — page furniture (a
373    /// full-bleed background fill, a decorative frame) rather than a table
374    /// primitive. GH#1656: `is_table_primitive`'s `< 1000 pt` bound admits
375    /// ordinary page-sized rectangles (A4, Letter), which then seed a
376    /// whole-page false-positive cluster; this predicate is how callers with
377    /// access to the page's MediaBox (`PathContent` itself carries none) can
378    /// exclude that furniture before it reaches the clusterer.
379    ///
380    /// `media_box` is `(llx, lly, urx, ury)` as returned by
381    /// `PdfDocument::get_page_media_box`. Requiring *both* dimensions to
382    /// clear the threshold is deliberate: a full-page-width footer rule
383    /// (wide, but a fraction of a point tall) must not be classified as page
384    /// furniture and dropped — it is a legitimate table/ruling primitive.
385    /// A degenerate `media_box` (zero or negative area, non-finite) can't
386    /// establish "most of the page", so this returns `false` — callers
387    /// should treat a failed MediaBox lookup as "leave every primitive
388    /// alone", not as license to filter on garbage. ~keep
389    pub fn is_page_frame_rectangle(&self, media_box: (f32, f32, f32, f32)) -> bool {
390        if !self.is_rectangle() {
391            return false;
392        }
393
394        let (llx, lly, urx, ury) = media_box;
395        let page_width = (urx - llx).abs();
396        let page_height = (ury - lly).abs();
397        if !page_width.is_finite() || !page_height.is_finite() || page_width <= 0.0 || page_height <= 0.0 {
398            return false;
399        }
400
401        let w = self.bbox.width.abs();
402        let h = self.bbox.height.abs();
403        if !w.is_finite() || !h.is_finite() {
404            return false;
405        }
406
407        w >= page_width * Self::PAGE_FRAME_COVERAGE_THRESHOLD && h >= page_height * Self::PAGE_FRAME_COVERAGE_THRESHOLD
408    }
409
410    /// Create a line path from (x1, y1) to (x2, y2).
411    ///
412    /// # Example
413    ///
414    /// ```ignore
415    /// let line = PathContent::line(10.0, 10.0, 100.0, 100.0);
416    /// ```
417    pub fn line(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
418        let ops = vec![PathOperation::MoveTo(x1, y1), PathOperation::LineTo(x2, y2)];
419        Self::from_operations(ops)
420    }
421
422    /// Create a rectangle path.
423    ///
424    /// # Example
425    ///
426    /// ```ignore
427    /// let rect = PathContent::rect(10.0, 10.0, 100.0, 50.0);
428    /// ```
429    pub fn rect(x: f32, y: f32, width: f32, height: f32) -> Self {
430        let ops = vec![PathOperation::Rectangle(x, y, width, height)];
431        Self::from_operations(ops)
432    }
433
434    /// Create an approximate circle path using Bezier curves.
435    ///
436    /// Uses 4 cubic Bezier curves to approximate a circle.
437    /// The approximation uses the constant k = 0.5522847498 for control points.
438    ///
439    /// # Example
440    ///
441    /// ```ignore
442    /// let circle = PathContent::circle(100.0, 100.0, 50.0);
443    /// ```
444    pub fn circle(cx: f32, cy: f32, radius: f32) -> Self {
445        // Magic constant for approximating a quarter circle with a cubic Bezier
446        // k = 4 * (sqrt(2) - 1) / 3 ≈ 0.5522847498 ~keep
447        const K: f32 = 0.552_284_8;
448        let k = radius * K;
449
450        let ops = vec![
451            PathOperation::MoveTo(cx, cy + radius),
452            PathOperation::CurveTo(cx + k, cy + radius, cx + radius, cy + k, cx + radius, cy),
453            PathOperation::CurveTo(cx + radius, cy - k, cx + k, cy - radius, cx, cy - radius),
454            PathOperation::CurveTo(cx - k, cy - radius, cx - radius, cy - k, cx - radius, cy),
455            PathOperation::CurveTo(cx - radius, cy + k, cx - k, cy + radius, cx, cy + radius),
456            PathOperation::ClosePath,
457        ];
458        Self::from_operations(ops)
459    }
460
461    /// Create a rounded rectangle path.
462    ///
463    /// # Arguments
464    ///
465    /// * `x` - X coordinate of the bottom-left corner
466    /// * `y` - Y coordinate of the bottom-left corner
467    /// * `width` - Width of the rectangle
468    /// * `height` - Height of the rectangle
469    /// * `radius` - Corner radius (clamped to min(width, height) / 2)
470    ///
471    /// # Example
472    ///
473    /// ```ignore
474    /// let rounded = PathContent::rounded_rect(10.0, 10.0, 100.0, 50.0, 5.0);
475    /// ```
476    pub fn rounded_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Self {
477        let max_radius = width.min(height) / 2.0;
478        let r = radius.min(max_radius).max(0.0);
479
480        if r <= 0.0 {
481            return Self::rect(x, y, width, height);
482        }
483
484        // Magic constant for approximating a quarter circle with a cubic Bezier ~keep
485        const K: f32 = 0.552_284_8;
486        let k = r * K;
487
488        let x_right = x + width;
489        let y_top = y + height;
490
491        let ops = vec![
492            PathOperation::MoveTo(x + r, y),
493            PathOperation::LineTo(x_right - r, y),
494            PathOperation::CurveTo(x_right - r + k, y, x_right, y + r - k, x_right, y + r),
495            PathOperation::LineTo(x_right, y_top - r),
496            PathOperation::CurveTo(x_right, y_top - r + k, x_right - r + k, y_top, x_right - r, y_top),
497            PathOperation::LineTo(x + r, y_top),
498            PathOperation::CurveTo(x + r - k, y_top, x, y_top - r + k, x, y_top - r),
499            PathOperation::LineTo(x, y + r),
500            PathOperation::CurveTo(x, y + r - k, x + r - k, y, x + r, y),
501            PathOperation::ClosePath,
502        ];
503        Self::from_operations(ops)
504    }
505
506    /// Flatten this path into polylines — one `Vec<(x, y)>` per subpath.
507    ///
508    /// Straight segments (`MoveTo`/`LineTo`) pass through unchanged; cubic
509    /// Béziers (`CurveTo`) are adaptively subdivided so the resulting polyline
510    /// stays within `tolerance` of the true curve. A `Rectangle` becomes its
511    /// own closed 5-point subpath, and `ClosePath` appends the subpath's start
512    /// point. A new subpath begins at every `MoveTo` (and every `Rectangle`).
513    ///
514    /// `tolerance` is expressed in the same coordinate space as the path's own
515    /// points, and the returned points are in that space verbatim. For paths
516    /// from [`crate::document::PdfDocument::extract_paths`] that is the page's
517    /// default user space (PDF points, y-up), with any content-stream `cm` and
518    /// Form-XObject `/Matrix` transforms already folded in — so a path drawn
519    /// under a scaling transform is returned (and flattened) at its final
520    /// on-page size, and no tolerance rescaling is needed. It is *not* device or
521    /// pixel space: page `/Rotate` and rendering DPI are not applied. Smaller
522    /// values yield more, finer points. A non-positive or non-finite tolerance
523    /// is floored to a small epsilon so subdivision always terminates.
524    ///
525    /// This is intended for consumers that need sampled coordinates rather than
526    /// drawing operators — e.g. digitising chart/ECG/CAD traces from vector PDFs.
527    ///
528    /// # Examples
529    ///
530    /// ```
531    /// use xberg_native_pdf::elements::{PathContent, PathOperation};
532    ///
533    /// let path = PathContent::from_operations(vec![
534    ///     PathOperation::MoveTo(0.0, 0.0),
535    ///     PathOperation::CurveTo(0.0, 10.0, 10.0, 10.0, 10.0, 0.0),
536    /// ]);
537    /// let subpaths = path.to_points(0.25);
538    /// assert_eq!(subpaths.len(), 1);
539    /// assert_eq!(subpaths[0][0], (0.0, 0.0)); // starts at P0
540    /// ```
541    pub fn to_points(&self, tolerance: f32) -> Vec<Vec<(f32, f32)>> {
542        // A non-finite or non-positive tolerance would make the flatness test
543        // never pass, recursing to the depth cap on every curve. Floor it. ~keep
544        let tol = if tolerance.is_finite() && tolerance > 0.0 {
545            tolerance
546        } else {
547            FLATTEN_TOLERANCE_FLOOR
548        };
549
550        let mut subpaths: Vec<Vec<(f32, f32)>> = Vec::new();
551        let mut current: Vec<(f32, f32)> = Vec::new();
552        let mut pos = (0.0_f32, 0.0_f32);
553        let mut start = (0.0_f32, 0.0_f32);
554
555        for op in &self.operations {
556            match *op {
557                PathOperation::MoveTo(x, y) => {
558                    // `m` begins a new subpath; a consecutive `m` overrides the
559                    // previous one with "no vestige" (§8.5.2, Table 59), which
560                    // flush_subpath honours by dropping the lone start point. ~keep
561                    flush_subpath(&mut current, &mut subpaths);
562                    pos = (x, y);
563                    start = pos;
564                    current.push(pos);
565                }
566                PathOperation::LineTo(x, y) => {
567                    // A segment with no open subpath (e.g. right after `re`/`h`,
568                    // or a malformed leading `l`) starts at the current point. ~keep
569                    if current.is_empty() {
570                        start = pos;
571                        current.push(pos);
572                    }
573                    pos = (x, y);
574                    current.push(pos);
575                }
576                PathOperation::CurveTo(c1x, c1y, c2x, c2y, ex, ey) => {
577                    if current.is_empty() {
578                        start = pos;
579                        current.push(pos);
580                    }
581                    flatten_cubic(pos, (c1x, c1y), (c2x, c2y), (ex, ey), tol, 0, &mut current);
582                    pos = (ex, ey);
583                }
584                PathOperation::Rectangle(x, y, w, h) => {
585                    flush_subpath(&mut current, &mut subpaths);
586                    // `re` is a complete closed subpath equivalent to
587                    // `x y m / (x+w) y l / (x+w) (y+h) l / x (y+h) l / h`
588                    // (§8.5.2, Table 59). The current point afterwards is (x, y). ~keep
589                    subpaths.push(vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]);
590                    pos = (x, y);
591                    start = pos;
592                }
593                PathOperation::ClosePath => {
594                    // `h` appends a segment back to the subpath start and
595                    // terminates the subpath; a following segment begins a new
596                    // one (§8.5.2, Table 59). On an already-closed/empty subpath
597                    // it does nothing. ~keep
598                    if !current.is_empty() {
599                        current.push(start);
600                        flush_subpath(&mut current, &mut subpaths);
601                        pos = start;
602                    }
603                }
604            }
605        }
606        flush_subpath(&mut current, &mut subpaths);
607        subpaths
608    }
609}
610
611/// True when `operations[index]` is a `MoveTo` that begins a subpath no
612/// segment follows. Such a subpath paints nothing (§8.5.2: path construction
613/// operators place no marks; a lone point has "no vestige"), so it must not
614/// extend the bounding box. GH#1759: a footer band followed by a stray
615/// `0 842 m` otherwise reports a page-sized box, which unions every rule on
616/// the page into one cluster. ~keep
617fn is_degenerate_move_to(operations: &[PathOperation], index: usize) -> bool {
618    !matches!(
619        operations.get(index + 1),
620        Some(PathOperation::LineTo(..) | PathOperation::CurveTo(..) | PathOperation::ClosePath)
621    )
622}
623
624/// Compute the bounding box of a sequence of path operations.
625///
626/// Shared by [`PathContent::from_operations`] and
627/// `PathExtractor::finalize_path`, which must agree exactly: the two were
628/// independent, byte-identical copies, so GH#1759 had to be fixed twice.
629pub(crate) fn path_operations_bbox(operations: &[PathOperation]) -> Rect {
630    let mut min_x = f32::MAX;
631    let mut min_y = f32::MAX;
632    let mut max_x = f32::MIN;
633    let mut max_y = f32::MIN;
634
635    for (index, op) in operations.iter().enumerate() {
636        match op {
637            PathOperation::MoveTo(..) if is_degenerate_move_to(operations, index) => {}
638            PathOperation::MoveTo(x, y) | PathOperation::LineTo(x, y) => {
639                min_x = min_x.min(*x);
640                min_y = min_y.min(*y);
641                max_x = max_x.max(*x);
642                max_y = max_y.max(*y);
643            }
644            PathOperation::CurveTo(x1, y1, x2, y2, x3, y3) => {
645                for (x, y) in [(*x1, *y1), (*x2, *y2), (*x3, *y3)] {
646                    min_x = min_x.min(x);
647                    min_y = min_y.min(y);
648                    max_x = max_x.max(x);
649                    max_y = max_y.max(y);
650                }
651            }
652            PathOperation::Rectangle(x, y, w, h) => {
653                // `PathExtractor::rectangle` stores the CTM-transformed
654                // delta, so a flipping CTM (negative `a`/`d`) or a
655                // negative-extent `re` yields a negative `w`/`h`. Order the
656                // corners here — `Rect::new` normalises a whole rect, but is
657                // handed pre-reduced min/extent values below. ~keep
658                min_x = min_x.min(x.min(*x + *w));
659                min_y = min_y.min(y.min(*y + *h));
660                max_x = max_x.max(x.max(*x + *w));
661                max_y = max_y.max(y.max(*y + *h));
662            }
663            PathOperation::ClosePath => {}
664        }
665    }
666
667    if min_x == f32::MAX {
668        Rect::new(0.0, 0.0, 0.0, 0.0)
669    } else {
670        Rect::new(min_x, min_y, max_x - min_x, max_y - min_y)
671    }
672}
673
674/// Minimum flattening tolerance (path units) used by [`PathContent::to_points`]
675/// when given a non-positive or non-finite value, so subdivision terminates.
676const FLATTEN_TOLERANCE_FLOOR: f32 = 1e-3;
677
678/// Hard recursion-depth backstop for adaptive cubic subdivision. The flatness
679/// test halts long before this in practice; it only guards pathological input.
680const FLATTEN_MAX_DEPTH: u8 = 24;
681
682/// Move a finished subpath from `current` into `out`, discarding it if it has
683/// fewer than two points. A lone point is a subpath that adds no segment and
684/// paints nothing (§8.5.2: construction operators place no marks), e.g. a
685/// trailing `m` or the first of two consecutive `m` operators ("no vestige").
686fn flush_subpath(current: &mut Vec<(f32, f32)>, out: &mut Vec<Vec<(f32, f32)>>) {
687    if current.len() >= 2 {
688        out.push(std::mem::take(current));
689    } else {
690        current.clear();
691    }
692}
693
694/// Adaptively subdivide a cubic Bézier (`p0`→`p3`, controls `p1`,`p2`),
695/// appending flattened vertices to `out`. The start point `p0` is assumed to
696/// already be the last element of `out`, so only intermediate vertices and the
697/// endpoint `p3` are pushed.
698fn flatten_cubic(
699    p0: (f32, f32),
700    p1: (f32, f32),
701    p2: (f32, f32),
702    p3: (f32, f32),
703    tol: f32,
704    depth: u8,
705    out: &mut Vec<(f32, f32)>,
706) {
707    // The curve lies inside the convex hull of its control points; since p0 and
708    // p3 are on the chord, the curve's deviation from the chord is bounded by
709    // the larger control-point offset. When that is within `tol`, a single
710    // straight segment is a good-enough approximation. ~keep
711    if depth >= FLATTEN_MAX_DEPTH || cubic_is_flat(p0, p1, p2, p3, tol) {
712        out.push(p3);
713        return;
714    }
715    // de Casteljau split at t = 0.5; the split point lies on the true curve. ~keep
716    let p01 = midpoint(p0, p1);
717    let p12 = midpoint(p1, p2);
718    let p23 = midpoint(p2, p3);
719    let p012 = midpoint(p01, p12);
720    let p123 = midpoint(p12, p23);
721    let mid = midpoint(p012, p123);
722    flatten_cubic(p0, p01, p012, mid, tol, depth + 1, out);
723    flatten_cubic(mid, p123, p23, p3, tol, depth + 1, out);
724}
725
726fn midpoint(a: (f32, f32), b: (f32, f32)) -> (f32, f32) {
727    ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
728}
729
730/// True when both control points lie within `tol` of the chord `p0`→`p3`.
731fn cubic_is_flat(p0: (f32, f32), p1: (f32, f32), p2: (f32, f32), p3: (f32, f32), tol: f32) -> bool {
732    dist_point_to_segment(p1, p0, p3).max(dist_point_to_segment(p2, p0, p3)) <= tol
733}
734
735/// Shortest distance from point `p` to the line segment `a`→`b`.
736fn dist_point_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
737    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
738    let len2 = dx * dx + dy * dy;
739    if len2 == 0.0 {
740        // Degenerate chord (a == b): distance to the single point. ~keep
741        return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
742    }
743    let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0);
744    let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
745    ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
746}
747
748impl Default for PathContent {
749    fn default() -> Self {
750        Self {
751            bbox: Rect::new(0.0, 0.0, 0.0, 0.0),
752            operations: Vec::new(),
753            stroke_color: Some(Color::black()),
754            fill_color: None,
755            stroke_width: 1.0,
756            line_cap: LineCap::Butt,
757            line_join: LineJoin::Miter,
758            dash_pattern: None,
759            matrix: None,
760            reading_order: None,
761            artifact_type: None,
762            layer: None,
763        }
764    }
765}
766
767/// A single path operation.
768#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
769pub enum PathOperation {
770    /// Move to a point (m operator)
771    MoveTo(f32, f32),
772    /// Line to a point (l operator)
773    LineTo(f32, f32),
774    /// Bezier curve to a point (c operator)
775    /// (control1_x, control1_y, control2_x, control2_y, end_x, end_y)
776    CurveTo(f32, f32, f32, f32, f32, f32),
777    /// Rectangle (re operator)
778    /// (x, y, width, height)
779    Rectangle(f32, f32, f32, f32),
780    /// Close the current path (h operator)
781    ClosePath,
782}
783
784/// Line cap style for strokes.
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
786pub enum LineCap {
787    /// Butt cap - line ends exactly at endpoint
788    #[default]
789    Butt,
790    /// Round cap - semicircle at endpoint
791    Round,
792    /// Square cap - half square at endpoint
793    Square,
794}
795
796/// Line join style for strokes.
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
798pub enum LineJoin {
799    /// Miter join - sharp corner
800    #[default]
801    Miter,
802    /// Round join - circular arc
803    Round,
804    /// Bevel join - diagonal corner
805    Bevel,
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn test_path_content_creation() {
814        let path = PathContent::new(Rect::new(0.0, 0.0, 100.0, 100.0))
815            .with_stroke(Color::black())
816            .with_stroke_width(2.0);
817
818        assert!(path.has_stroke());
819        assert!(!path.has_fill());
820        assert_eq!(path.stroke_width, 2.0);
821    }
822
823    #[test]
824    fn test_path_from_operations() {
825        let ops = vec![
826            PathOperation::MoveTo(10.0, 10.0),
827            PathOperation::LineTo(50.0, 10.0),
828            PathOperation::LineTo(50.0, 50.0),
829            PathOperation::LineTo(10.0, 50.0),
830            PathOperation::ClosePath,
831        ];
832
833        let path = PathContent::from_operations(ops);
834
835        assert_eq!(path.bbox.x, 10.0);
836        assert_eq!(path.bbox.y, 10.0);
837        assert_eq!(path.bbox.width, 40.0);
838        assert_eq!(path.bbox.height, 40.0);
839    }
840
841    #[test]
842    fn test_path_with_fill() {
843        let path = PathContent::new(Rect::new(0.0, 0.0, 100.0, 100.0)).with_fill(Color::new(1.0, 0.0, 0.0));
844
845        assert!(path.has_fill());
846        assert!(path.has_stroke());
847    }
848
849    #[test]
850    fn test_compute_bbox_from_rectangle() {
851        let ops = vec![PathOperation::Rectangle(20.0, 30.0, 100.0, 50.0)];
852        let path = PathContent::from_operations(ops);
853
854        assert_eq!(path.bbox.x, 20.0);
855        assert_eq!(path.bbox.y, 30.0);
856        assert_eq!(path.bbox.width, 100.0);
857        assert_eq!(path.bbox.height, 50.0);
858    }
859
860    #[test]
861    fn is_page_frame_rectangle_excludes_a4_and_letter_page_backgrounds() {
862        // GH#1656: a full-page background rect must be recognized as page
863        // furniture against both an A4 and a US-Letter MediaBox. ~keep
864        let a4_media_box = (0.0, 0.0, 595.28, 842.0);
865        let a4_frame = PathContent::rect(0.0, 0.0, 595.28, 842.0);
866        assert!(a4_frame.is_page_frame_rectangle(a4_media_box));
867
868        let letter_media_box = (0.0, 0.0, 612.0, 792.0);
869        let letter_frame = PathContent::rect(0.0, 0.0, 612.0, 792.0);
870        assert!(letter_frame.is_page_frame_rectangle(letter_media_box));
871    }
872
873    #[test]
874    fn is_page_frame_rectangle_admits_a_genuine_table_cell_sized_box() {
875        // A table cell box is nowhere near 90% of the page in either
876        // dimension and must not be treated as furniture. ~keep
877        let media_box = (0.0, 0.0, 595.28, 842.0);
878        let cell = PathContent::rect(45.36, 472.70, 138.24, 34.02);
879        assert!(!cell.is_page_frame_rectangle(media_box));
880    }
881
882    #[test]
883    fn is_page_frame_rectangle_rejects_degenerate_media_box() {
884        // A missing/zero/non-finite MediaBox must not be treated as
885        // license to filter — leave the primitive alone instead. ~keep
886        let page_sized = PathContent::rect(0.0, 0.0, 595.28, 842.0);
887        assert!(!page_sized.is_page_frame_rectangle((0.0, 0.0, 0.0, 0.0)));
888        assert!(!page_sized.is_page_frame_rectangle((0.0, 0.0, f32::NAN, 842.0)));
889        assert!(!page_sized.is_page_frame_rectangle((0.0, 0.0, f32::INFINITY, 842.0)));
890    }
891
892    #[test]
893    fn is_page_frame_rectangle_ignores_non_rectangle_paths() {
894        // A full-page-width line is not a rectangle at all and must never
895        // be classified as page-frame furniture, regardless of extent. ~keep
896        let media_box = (0.0, 0.0, 595.28, 842.0);
897        let full_width_line = PathContent::line(0.0, 48.30, 595.28, 48.30);
898        assert!(!full_width_line.is_page_frame_rectangle(media_box));
899    }
900
901    #[test]
902    fn footer_rule_full_page_width_stays_a_table_primitive() {
903        // Proves the page-frame filter does not over-filter: a thin
904        // horizontal rule spanning the full page width is real ruling, not
905        // furniture, because only ONE dimension (width) reaches page scale
906        // — is_page_frame_rectangle requires both. GH#1656. ~keep
907        let footer_rule = PathContent::line(0.0, 48.30, 595.28, 48.30);
908        assert!(footer_rule.is_table_primitive());
909        assert!(!footer_rule.is_page_frame_rectangle((0.0, 0.0, 595.28, 842.0)));
910    }
911
912    #[test]
913    fn trailing_move_to_does_not_extend_the_bounding_box_to_the_page() {
914        // GH#1759 carrier: `0 42.63 595.28 -28.35 re  0 842 m  f*`. The
915        // lone trailing `m` starts a subpath no segment follows, so it
916        // paints nothing and must not stretch the box to the page. ~keep
917        let path = PathContent::from_operations(vec![
918            PathOperation::Rectangle(0.0, 14.28, 595.28, 28.35),
919            PathOperation::MoveTo(0.0, 842.0),
920        ]);
921
922        assert!(
923            (path.bbox.y - 14.28).abs() < 1e-3,
924            "expected the footer band's own y (14.28), got {}",
925            path.bbox.y
926        );
927        assert!(
928            (path.bbox.height - 28.35).abs() < 1e-3,
929            "expected the footer band's own height (28.35), got {}",
930            path.bbox.height
931        );
932        assert!((path.bbox.x - 0.0).abs() < 1e-3, "got x {}", path.bbox.x);
933        assert!((path.bbox.width - 595.28).abs() < 1e-3, "got width {}", path.bbox.width);
934    }
935
936    #[test]
937    fn negative_extent_rectangle_is_normalised_when_mixed_with_a_segment() {
938        // `PathExtractor::rectangle` stores the CTM-transformed delta, so a
939        // flipping CTM or a negative-extent `re` yields a negative stored
940        // width/height. Mixed with another operation the box is built from
941        // min/max across operations, so the rectangle's own corners must be
942        // ordered first. GH#1759. ~keep
943        let path = PathContent::from_operations(vec![
944            PathOperation::Rectangle(100.0, 200.0, -40.0, -30.0),
945            PathOperation::LineTo(80.0, 180.0),
946        ]);
947
948        assert_eq!(path.bbox.x, 60.0, "expected the normalised left edge");
949        assert_eq!(path.bbox.y, 170.0, "expected the normalised bottom edge");
950        assert_eq!(path.bbox.width, 40.0, "expected the normalised width");
951        assert_eq!(path.bbox.height, 30.0, "expected the normalised height");
952    }
953
954    #[test]
955    fn move_to_followed_by_a_segment_still_contributes_both_points() {
956        // Control for GH#1759: a `MoveTo` that begins a real subpath is
957        // painted and must keep contributing its point. ~keep
958        let lined = PathContent::from_operations(vec![
959            PathOperation::MoveTo(10.0, 20.0),
960            PathOperation::LineTo(110.0, 120.0),
961        ]);
962        assert_eq!(lined.bbox.x, 10.0);
963        assert_eq!(lined.bbox.y, 20.0);
964        assert_eq!(lined.bbox.width, 100.0);
965        assert_eq!(lined.bbox.height, 100.0);
966
967        let curved = PathContent::from_operations(vec![
968            PathOperation::MoveTo(10.0, 20.0),
969            PathOperation::CurveTo(30.0, 40.0, 50.0, 60.0, 70.0, 80.0),
970        ]);
971        assert_eq!(curved.bbox.x, 10.0);
972        assert_eq!(curved.bbox.y, 20.0);
973
974        let closed = PathContent::from_operations(vec![
975            PathOperation::MoveTo(10.0, 20.0),
976            PathOperation::LineTo(110.0, 20.0),
977            PathOperation::MoveTo(10.0, 200.0),
978            PathOperation::ClosePath,
979        ]);
980        assert_eq!(closed.bbox.y, 20.0);
981        assert_eq!(closed.bbox.height, 180.0);
982    }
983
984    /// Ground-truth cubic Bézier evaluation, used to validate flattening.
985    fn cubic_at(p0: (f32, f32), p1: (f32, f32), p2: (f32, f32), p3: (f32, f32), t: f32) -> (f32, f32) {
986        let mt = 1.0 - t;
987        let x = mt * mt * mt * p0.0 + 3.0 * mt * mt * t * p1.0 + 3.0 * mt * t * t * p2.0 + t * t * t * p3.0;
988        let y = mt * mt * mt * p0.1 + 3.0 * mt * mt * t * p1.1 + 3.0 * mt * t * t * p2.1 + t * t * t * p3.1;
989        (x, y)
990    }
991
992    /// Shortest distance from a point to a polyline (min over its segments),
993    /// reusing the production segment-distance helper.
994    fn dist_to_polyline(p: (f32, f32), poly: &[(f32, f32)]) -> f32 {
995        poly.windows(2)
996            .map(|s| dist_point_to_segment(p, s[0], s[1]))
997            .fold(f32::MAX, f32::min)
998    }
999
1000    #[test]
1001    fn test_to_points_straight_line_passthrough() {
1002        let path = PathContent::from_operations(vec![
1003            PathOperation::MoveTo(10.0, 10.0),
1004            PathOperation::LineTo(100.0, 40.0),
1005        ]);
1006        let pts = path.to_points(0.5);
1007        assert_eq!(pts, vec![vec![(10.0, 10.0), (100.0, 40.0)]]);
1008    }
1009
1010    #[test]
1011    fn test_to_points_curve_endpoints_preserved() {
1012        let path = PathContent::from_operations(vec![
1013            PathOperation::MoveTo(0.0, 0.0),
1014            PathOperation::CurveTo(0.0, 100.0, 100.0, 100.0, 100.0, 0.0),
1015        ]);
1016        let sub = &path.to_points(0.1)[0];
1017        assert_eq!(sub.first().copied(), Some((0.0, 0.0)), "must start at P0");
1018        let last = sub.last().copied().unwrap();
1019        assert!(
1020            (last.0 - 100.0).abs() < 1e-3 && (last.1 - 0.0).abs() < 1e-3,
1021            "must end at P3, got {last:?}"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_to_points_curve_within_tolerance() {
1027        let p0 = (0.0, 0.0);
1028        let p1 = (0.0, 100.0);
1029        let p2 = (100.0, 100.0);
1030        let p3 = (100.0, 0.0);
1031        let tol = 0.5;
1032        let path = PathContent::from_operations(vec![
1033            PathOperation::MoveTo(p0.0, p0.1),
1034            PathOperation::CurveTo(p1.0, p1.1, p2.0, p2.1, p3.0, p3.1),
1035        ]);
1036        let poly = &path.to_points(tol)[0];
1037        // Every point on the true curve must lie within `tol` of the polyline. ~keep
1038        for i in 0..=200 {
1039            let t = i as f32 / 200.0;
1040            let truth = cubic_at(p0, p1, p2, p3, t);
1041            let d = dist_to_polyline(truth, poly);
1042            assert!(d <= tol + 1e-3, "curve point at t={t} is {d} from polyline (tol={tol})");
1043        }
1044    }
1045
1046    #[test]
1047    fn test_to_points_tolerance_monotonic() {
1048        let path = PathContent::from_operations(vec![
1049            PathOperation::MoveTo(0.0, 0.0),
1050            PathOperation::CurveTo(0.0, 100.0, 100.0, 100.0, 100.0, 0.0),
1051        ]);
1052        let coarse = path.to_points(10.0)[0].len();
1053        let fine = path.to_points(0.05)[0].len();
1054        assert!(
1055            fine >= coarse,
1056            "finer tolerance must not reduce point count ({fine} < {coarse})"
1057        );
1058        assert!(fine > 2, "fine flattening must densify the curve, got {fine}");
1059    }
1060
1061    #[test]
1062    fn test_to_points_rectangle_is_closed_subpath() {
1063        let path = PathContent::from_operations(vec![PathOperation::Rectangle(10.0, 20.0, 30.0, 40.0)]);
1064        let pts = path.to_points(1.0);
1065        assert_eq!(
1066            pts,
1067            vec![vec![
1068                (10.0, 20.0),
1069                (40.0, 20.0),
1070                (40.0, 60.0),
1071                (10.0, 60.0),
1072                (10.0, 20.0)
1073            ]]
1074        );
1075    }
1076
1077    #[test]
1078    fn test_to_points_segment_after_rectangle_continues_from_current_point() {
1079        // Per PDF §8.5.2 the current point after `re` is the rectangle's
1080        // lower-left; a following segment continues from there as a new subpath. ~keep
1081        let path = PathContent::from_operations(vec![
1082            PathOperation::Rectangle(0.0, 0.0, 10.0, 10.0),
1083            PathOperation::LineTo(20.0, 20.0),
1084        ]);
1085        let pts = path.to_points(1.0);
1086        assert_eq!(pts.len(), 2);
1087        assert_eq!(
1088            pts[0],
1089            vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]
1090        );
1091        assert_eq!(pts[1], vec![(0.0, 0.0), (20.0, 20.0)]);
1092    }
1093
1094    #[test]
1095    fn test_to_points_closepath_appends_start() {
1096        let path = PathContent::from_operations(vec![
1097            PathOperation::MoveTo(0.0, 0.0),
1098            PathOperation::LineTo(10.0, 0.0),
1099            PathOperation::LineTo(10.0, 10.0),
1100            PathOperation::ClosePath,
1101        ]);
1102        assert_eq!(
1103            path.to_points(1.0),
1104            vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 0.0)]]
1105        );
1106    }
1107
1108    #[test]
1109    fn test_to_points_closepath_terminates_subpath() {
1110        // Per §8.5.2 Table 59, `h` terminates the subpath; a following segment
1111        // begins a NEW subpath (seeded from the subpath start = current point),
1112        // rather than extending the closed loop. ~keep
1113        let path = PathContent::from_operations(vec![
1114            PathOperation::MoveTo(0.0, 0.0),
1115            PathOperation::LineTo(10.0, 0.0),
1116            PathOperation::ClosePath,
1117            PathOperation::LineTo(5.0, 5.0),
1118        ]);
1119        let pts = path.to_points(1.0);
1120        assert_eq!(pts.len(), 2, "h must terminate the subpath");
1121        assert_eq!(pts[0], vec![(0.0, 0.0), (10.0, 0.0), (0.0, 0.0)]);
1122        assert_eq!(pts[1], vec![(0.0, 0.0), (5.0, 5.0)]);
1123    }
1124
1125    #[test]
1126    fn test_to_points_consecutive_moveto_leaves_no_vestige() {
1127        // Per §8.5.2 Table 59, a consecutive `m` overrides the previous one with
1128        // no vestige; the orphaned start point must not appear as a subpath. ~keep
1129        let path = PathContent::from_operations(vec![
1130            PathOperation::MoveTo(1.0, 1.0),
1131            PathOperation::MoveTo(2.0, 2.0),
1132            PathOperation::LineTo(3.0, 3.0),
1133        ]);
1134        assert_eq!(path.to_points(1.0), vec![vec![(2.0, 2.0), (3.0, 3.0)]]);
1135    }
1136
1137    #[test]
1138    fn test_to_points_lone_moveto_is_dropped() {
1139        // A subpath that adds no segment paints nothing and yields no polyline. ~keep
1140        let path = PathContent::from_operations(vec![PathOperation::MoveTo(5.0, 5.0)]);
1141        assert!(path.to_points(1.0).is_empty());
1142    }
1143
1144    #[test]
1145    fn test_to_points_multiple_subpaths() {
1146        let path = PathContent::from_operations(vec![
1147            PathOperation::MoveTo(0.0, 0.0),
1148            PathOperation::LineTo(1.0, 0.0),
1149            PathOperation::MoveTo(5.0, 5.0),
1150            PathOperation::LineTo(6.0, 5.0),
1151        ]);
1152        assert_eq!(
1153            path.to_points(1.0),
1154            vec![vec![(0.0, 0.0), (1.0, 0.0)], vec![(5.0, 5.0), (6.0, 5.0)]]
1155        );
1156    }
1157
1158    #[test]
1159    fn test_to_points_empty() {
1160        let path = PathContent::from_operations(vec![]);
1161        assert!(path.to_points(1.0).is_empty());
1162    }
1163
1164    #[test]
1165    fn test_to_points_nonpositive_tolerance_terminates() {
1166        let path = PathContent::from_operations(vec![
1167            PathOperation::MoveTo(0.0, 0.0),
1168            PathOperation::CurveTo(0.0, 100.0, 100.0, 100.0, 100.0, 0.0),
1169        ]);
1170        for tol in [0.0, -1.0, f32::NAN] {
1171            let pts = path.to_points(tol);
1172            assert_eq!(pts.len(), 1);
1173            let n = pts[0].len();
1174            assert!(
1175                (2..100_000).contains(&n),
1176                "tol={tol} produced {n} points (expected bounded)"
1177            );
1178            assert!(pts[0].iter().all(|(x, y)| x.is_finite() && y.is_finite()));
1179        }
1180    }
1181
1182    #[test]
1183    fn test_to_points_curve_starts_at_current_point() {
1184        // The curve's implicit P0 is the current point (here the LineTo endpoint),
1185        // not the path origin. Guards against using the wrong start point. ~keep
1186        let path = PathContent::from_operations(vec![
1187            PathOperation::MoveTo(0.0, 0.0),
1188            PathOperation::LineTo(50.0, 0.0),
1189            PathOperation::CurveTo(60.0, 20.0, 70.0, 20.0, 80.0, 0.0),
1190        ]);
1191        let sub = &path.to_points(0.5)[0];
1192        assert_eq!(sub[0], (0.0, 0.0));
1193        assert_eq!(sub[1], (50.0, 0.0));
1194        // First flattened curve vertex departs from the LineTo endpoint (50,0),
1195        // so it must sit to its right (x > 50), never back near the origin. ~keep
1196        assert!(
1197            sub[2].0 > 50.0,
1198            "curve did not start at current point; sub[2]={:?}",
1199            sub[2]
1200        );
1201        assert!((sub.last().unwrap().0 - 80.0).abs() < 1e-3);
1202    }
1203
1204    #[test]
1205    fn test_to_points_degenerate_curve() {
1206        // All control points coincide: a zero-length curve must not subdivide forever. ~keep
1207        let path = PathContent::from_operations(vec![
1208            PathOperation::MoveTo(5.0, 5.0),
1209            PathOperation::CurveTo(5.0, 5.0, 5.0, 5.0, 5.0, 5.0),
1210        ]);
1211        let sub = &path.to_points(0.1)[0];
1212        assert!(sub.len() < 10, "degenerate curve over-subdivided: {} points", sub.len());
1213        assert!(sub.iter().all(|&p| p == (5.0, 5.0)));
1214    }
1215}