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}