Skip to main content

rpdfium_graphics/
cfx_path.rs

1// Derived from PDFium's core/fxge/cfx_path.h
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Path construction operations and styling.
7//!
8//! PDF path operations follow the PostScript model: a current path is built
9//! by appending segments (move, line, curve, close), then painted with a
10//! fill and/or stroke operation.
11
12use rpdfium_core::Rect;
13
14/// A single path construction operation.
15///
16/// Coordinates are in user space (pre-CTM transformation).
17#[derive(Debug, Clone, PartialEq)]
18pub enum PathOp {
19    /// Move to a new point, starting a new subpath.
20    MoveTo { x: f32, y: f32 },
21    /// Draw a straight line to the given point.
22    LineTo { x: f32, y: f32 },
23    /// Draw a cubic Bezier curve with two control points and an endpoint.
24    CurveTo {
25        x1: f32,
26        y1: f32,
27        x2: f32,
28        y2: f32,
29        x3: f32,
30        y3: f32,
31    },
32    /// Close the current subpath with a straight line back to its start.
33    Close,
34}
35
36/// Fill rule for path operations (PDF spec Table 60).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
38pub enum FillRule {
39    /// Non-zero winding number rule (default).
40    #[default]
41    NonZero,
42    /// Even-odd rule.
43    EvenOdd,
44}
45
46/// Line cap style (PDF spec Table 54).
47///
48/// Specifies the shape to be used at the ends of open subpaths when stroked.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
50#[repr(u8)]
51pub enum LineCapStyle {
52    /// Butt cap: stroke is squared off at the endpoint.
53    #[default]
54    Butt = 0,
55    /// Round cap: semicircular arc at the endpoint.
56    Round = 1,
57    /// Projecting square cap: extends half the line width past endpoint.
58    Square = 2,
59}
60
61impl LineCapStyle {
62    /// Convert from a PDF integer value.
63    pub fn from_i64(value: i64) -> Self {
64        match value {
65            0 => Self::Butt,
66            1 => Self::Round,
67            2 => Self::Square,
68            _ => Self::Butt,
69        }
70    }
71}
72
73/// Line join style (PDF spec Table 55).
74///
75/// Specifies the shape to be used at corners of stroked paths.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77#[repr(u8)]
78pub enum LineJoinStyle {
79    /// Miter join: outer edges extended to meet at a point.
80    #[default]
81    Miter = 0,
82    /// Round join: circular arc between outer edges.
83    Round = 1,
84    /// Bevel join: straight line between outer edges.
85    Bevel = 2,
86}
87
88impl LineJoinStyle {
89    /// Convert from a PDF integer value.
90    pub fn from_i64(value: i64) -> Self {
91        match value {
92            0 => Self::Miter,
93            1 => Self::Round,
94            2 => Self::Bevel,
95            _ => Self::Miter,
96        }
97    }
98}
99
100/// Dash pattern for stroked paths (PDF spec §8.4.3.6).
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct DashPattern {
103    /// Alternating dash and gap lengths.
104    pub array: Vec<f32>,
105    /// Phase offset into the dash pattern.
106    pub phase: f32,
107}
108
109/// Style for path rendering, combining fill rule, stroke, and line attributes.
110///
111/// Default values match the PDF initial graphics state (PDF spec §8.4.1).
112#[derive(Debug, Clone, PartialEq)]
113pub struct PathStyle {
114    /// Fill rule, or `None` for no fill.
115    pub fill: Option<FillRule>,
116    /// Whether to stroke the path.
117    pub stroke: bool,
118    /// Line width in user space units (default: 1.0).
119    pub line_width: f32,
120    /// Line cap style (default: Butt).
121    pub line_cap: LineCapStyle,
122    /// Line join style (default: Miter).
123    pub line_join: LineJoinStyle,
124    /// Miter limit (default: 10.0).
125    pub miter_limit: f32,
126    /// Dash pattern, or `None` for solid line.
127    pub dash: Option<DashPattern>,
128}
129
130impl Default for PathStyle {
131    fn default() -> Self {
132        Self {
133            fill: None,
134            stroke: false,
135            line_width: 1.0,
136            line_cap: LineCapStyle::default(),
137            line_join: LineJoinStyle::default(),
138            miter_limit: 10.0,
139            dash: None,
140        }
141    }
142}
143
144/// Computes the axis-aligned bounding box of a path.
145///
146/// Corresponds to `CFX_Path::GetBoundingBox()` in upstream PDFium.
147/// All coordinates including Bézier control points are included (conservative
148/// bound — the true tight bound for curves may be smaller, but this matches
149/// upstream behaviour).
150///
151/// Returns `None` if `ops` is empty or contains only [`PathOp::Close`].
152pub fn bounding_box(ops: &[PathOp]) -> Option<Rect> {
153    let mut min_x = f32::INFINITY;
154    let mut min_y = f32::INFINITY;
155    let mut max_x = f32::NEG_INFINITY;
156    let mut max_y = f32::NEG_INFINITY;
157    let mut found = false;
158
159    for op in ops {
160        match op {
161            PathOp::MoveTo { x, y } | PathOp::LineTo { x, y } => {
162                min_x = min_x.min(*x);
163                min_y = min_y.min(*y);
164                max_x = max_x.max(*x);
165                max_y = max_y.max(*y);
166                found = true;
167            }
168            PathOp::CurveTo {
169                x1,
170                y1,
171                x2,
172                y2,
173                x3,
174                y3,
175            } => {
176                min_x = min_x.min(*x1).min(*x2).min(*x3);
177                min_y = min_y.min(*y1).min(*y2).min(*y3);
178                max_x = max_x.max(*x1).max(*x2).max(*x3);
179                max_y = max_y.max(*y1).max(*y2).max(*y3);
180                found = true;
181            }
182            PathOp::Close => {}
183        }
184    }
185
186    if found {
187        Some(Rect::new(
188            min_x as f64,
189            min_y as f64,
190            max_x as f64,
191            max_y as f64,
192        ))
193    } else {
194        None
195    }
196}
197
198/// Upstream-aligned alias for [`bounding_box`].
199///
200/// Corresponds to `CFX_Path::GetBoundingBox()` in upstream PDFium.
201#[inline]
202pub fn get_bounding_box(ops: &[PathOp]) -> Option<Rect> {
203    bounding_box(ops)
204}
205
206/// Computes a conservative axis-aligned bounding box for a stroked path.
207///
208/// Corresponds to `CFX_Path::GetBoundingBoxForStrokePath()` in upstream PDFium.
209///
210/// The returned box is the path bounding box inflated by the maximum possible
211/// stroke outset:
212/// - At line caps: extends `line_width / 2` past the endpoint.
213/// - At miter corners: extends up to `miter_limit * line_width / 2`.
214///
215/// The inflation is therefore `max(line_width / 2, miter_limit * line_width / 2)`.
216/// Since `miter_limit` is normally ≥ 1, this simplifies to `miter_limit * line_width / 2`.
217///
218/// Returns `None` if the path is empty.
219pub fn bounding_box_for_stroke_path(
220    ops: &[PathOp],
221    line_width: f32,
222    miter_limit: f32,
223) -> Option<Rect> {
224    let inner = bounding_box(ops)?;
225    let half_lw = line_width / 2.0;
226    let inflate = (miter_limit * half_lw).max(half_lw) as f64;
227    Some(Rect::new(
228        inner.left - inflate,
229        inner.bottom - inflate,
230        inner.right + inflate,
231        inner.top + inflate,
232    ))
233}
234
235/// Upstream-aligned alias for [`bounding_box_for_stroke_path`].
236///
237/// Corresponds to `CFX_Path::GetBoundingBoxForStrokePath()` in upstream PDFium.
238#[inline]
239pub fn get_bounding_box_for_stroke_path(
240    ops: &[PathOp],
241    line_width: f32,
242    miter_limit: f32,
243) -> Option<Rect> {
244    bounding_box_for_stroke_path(ops, line_width, miter_limit)
245}
246
247/// Returns `true` if the path ops describe a simple axis-aligned rectangle.
248///
249/// Corresponds to `CFX_Path::IsRect()` in upstream PDFium.
250/// A path qualifies if it has exactly 4 `LineTo` ops preceded by a `MoveTo` (and optional
251/// `Close`), forming a closed axis-aligned rectangle.
252pub fn is_rect(ops: &[PathOp]) -> bool {
253    rect_if_axis_aligned(ops).is_some()
254}
255
256/// Detects whether a sequence of path ops describes a simple axis-aligned rectangle.
257///
258/// Corresponds to the `CFX_Path::IsRect()` / `CFX_Path::GetRect()` pattern in upstream PDFium.
259/// A path qualifies if it has exactly 4 `LineTo` ops preceded by a `MoveTo` (and optional
260/// `Close`), forming a closed axis-aligned rectangle.
261///
262/// Returns the detected rectangle if the path is axis-aligned, or `None` otherwise.
263pub fn rect_if_axis_aligned(ops: &[PathOp]) -> Option<Rect> {
264    // Normalise: ignore trailing Close
265    let effective: Vec<&PathOp> = ops
266        .iter()
267        .filter(|op| !matches!(op, PathOp::Close))
268        .collect();
269    // Expect: MoveTo, LineTo, LineTo, LineTo, LineTo (rectangle = 5 ops)
270    if effective.len() != 5 {
271        return None;
272    }
273    let (mx, my) = match effective[0] {
274        PathOp::MoveTo { x, y } => (*x, *y),
275        _ => return None,
276    };
277    let pts: Option<Vec<(f32, f32)>> = effective[1..]
278        .iter()
279        .map(|op| match op {
280            PathOp::LineTo { x, y } => Some((*x, *y)),
281            _ => None,
282        })
283        .collect();
284    let pts = pts?;
285    // Collect all x and y values; require exactly 2 distinct values each
286    let all_x = [mx, pts[0].0, pts[1].0, pts[2].0, pts[3].0];
287    let all_y = [my, pts[0].1, pts[1].1, pts[2].1, pts[3].1];
288    let mut xs: Vec<f32> = all_x.to_vec();
289    let mut ys: Vec<f32> = all_y.to_vec();
290    xs.dedup();
291    ys.dedup();
292    // After dedup on sorted values, 2 distinct means axis-aligned rectangle
293    xs.sort_by(f32::total_cmp);
294    ys.sort_by(f32::total_cmp);
295    xs.dedup();
296    ys.dedup();
297    if xs.len() == 2 && ys.len() == 2 {
298        Some(Rect::new(
299            xs[0] as f64,
300            ys[0] as f64,
301            xs[1] as f64,
302            ys[1] as f64,
303        ))
304    } else {
305        None
306    }
307}
308
309/// Upstream-aligned alias for [`rect_if_axis_aligned`].
310///
311/// Corresponds to `CFX_Path::GetRect()` in upstream PDFium.
312#[inline]
313pub fn get_rect(ops: &[PathOp]) -> Option<Rect> {
314    rect_if_axis_aligned(ops)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_line_cap_from_i64() {
323        assert_eq!(LineCapStyle::from_i64(0), LineCapStyle::Butt);
324        assert_eq!(LineCapStyle::from_i64(1), LineCapStyle::Round);
325        assert_eq!(LineCapStyle::from_i64(2), LineCapStyle::Square);
326        assert_eq!(LineCapStyle::from_i64(99), LineCapStyle::Butt); // invalid → default
327    }
328
329    #[test]
330    fn test_line_join_from_i64() {
331        assert_eq!(LineJoinStyle::from_i64(0), LineJoinStyle::Miter);
332        assert_eq!(LineJoinStyle::from_i64(1), LineJoinStyle::Round);
333        assert_eq!(LineJoinStyle::from_i64(2), LineJoinStyle::Bevel);
334        assert_eq!(LineJoinStyle::from_i64(-1), LineJoinStyle::Miter); // invalid → default
335    }
336
337    #[test]
338    fn test_path_style_default() {
339        let style = PathStyle::default();
340        assert!(style.fill.is_none());
341        assert!(!style.stroke);
342        assert_eq!(style.line_width, 1.0);
343        assert_eq!(style.line_cap, LineCapStyle::Butt);
344        assert_eq!(style.line_join, LineJoinStyle::Miter);
345        assert_eq!(style.miter_limit, 10.0);
346        assert!(style.dash.is_none());
347    }
348
349    #[test]
350    fn test_dash_pattern_default() {
351        let dash = DashPattern::default();
352        assert!(dash.array.is_empty());
353        assert_eq!(dash.phase, 0.0);
354    }
355
356    #[test]
357    fn test_path_op_clone() {
358        let op = PathOp::CurveTo {
359            x1: 1.0,
360            y1: 2.0,
361            x2: 3.0,
362            y2: 4.0,
363            x3: 5.0,
364            y3: 6.0,
365        };
366        let op2 = op.clone();
367        assert_eq!(op, op2);
368    }
369
370    #[test]
371    fn test_fill_rule_default() {
372        assert_eq!(FillRule::default(), FillRule::NonZero);
373    }
374
375    #[test]
376    fn test_bounding_box_empty() {
377        assert_eq!(bounding_box(&[]), None);
378        assert_eq!(bounding_box(&[PathOp::Close]), None);
379    }
380
381    #[test]
382    fn test_bounding_box_line() {
383        let ops = vec![
384            PathOp::MoveTo { x: 10.0, y: 20.0 },
385            PathOp::LineTo { x: 50.0, y: 80.0 },
386        ];
387        let bb = bounding_box(&ops).unwrap();
388        assert_eq!(bb.left, 10.0);
389        assert_eq!(bb.bottom, 20.0);
390        assert_eq!(bb.right, 50.0);
391        assert_eq!(bb.top, 80.0);
392    }
393
394    #[test]
395    fn test_bounding_box_rect_path() {
396        let ops = vec![
397            PathOp::MoveTo { x: 0.0, y: 0.0 },
398            PathOp::LineTo { x: 100.0, y: 0.0 },
399            PathOp::LineTo { x: 100.0, y: 200.0 },
400            PathOp::LineTo { x: 0.0, y: 200.0 },
401            PathOp::Close,
402        ];
403        let bb = bounding_box(&ops).unwrap();
404        assert_eq!(bb.left, 0.0);
405        assert_eq!(bb.bottom, 0.0);
406        assert_eq!(bb.right, 100.0);
407        assert_eq!(bb.top, 200.0);
408    }
409
410    #[test]
411    fn test_bounding_box_curve_includes_control_points() {
412        let ops = vec![PathOp::CurveTo {
413            x1: -5.0,
414            y1: 30.0,
415            x2: 60.0,
416            y2: -10.0,
417            x3: 40.0,
418            y3: 50.0,
419        }];
420        let bb = bounding_box(&ops).unwrap();
421        assert_eq!(bb.left as f32, -5.0_f32);
422        assert_eq!(bb.bottom as f32, -10.0_f32);
423        assert_eq!(bb.right as f32, 60.0_f32);
424        assert_eq!(bb.top as f32, 50.0_f32);
425    }
426
427    #[test]
428    fn test_bounding_box_negative_coords() {
429        let ops = vec![
430            PathOp::MoveTo {
431                x: -100.0,
432                y: -200.0,
433            },
434            PathOp::LineTo { x: -10.0, y: -5.0 },
435        ];
436        let bb = bounding_box(&ops).unwrap();
437        assert_eq!(bb.left as f32, -100.0_f32);
438        assert_eq!(bb.bottom as f32, -200.0_f32);
439        assert_eq!(bb.right as f32, -10.0_f32);
440        assert_eq!(bb.top as f32, -5.0_f32);
441    }
442
443    #[test]
444    fn test_stroke_bounding_box_inflates_by_half_line_width() {
445        let ops = vec![
446            PathOp::MoveTo { x: 10.0, y: 10.0 },
447            PathOp::LineTo { x: 50.0, y: 10.0 },
448        ];
449        // miter_limit=10.0, line_width=4.0 → inflate = max(10*2, 2) = 20
450        let bb = bounding_box_for_stroke_path(&ops, 4.0, 10.0).unwrap();
451        assert!((bb.left - (10.0 - 20.0)).abs() < 0.01);
452        assert!((bb.right - (50.0 + 20.0)).abs() < 0.01);
453        assert!((bb.bottom - (10.0 - 20.0)).abs() < 0.01);
454        assert!((bb.top - (10.0 + 20.0)).abs() < 0.01);
455    }
456
457    #[test]
458    fn test_stroke_bounding_box_min_inflate_is_half_lw() {
459        // miter_limit=0.5 < 1.0 → inflate capped to half_lw = 1.0
460        let ops = vec![
461            PathOp::MoveTo { x: 0.0, y: 0.0 },
462            PathOp::LineTo { x: 10.0, y: 0.0 },
463        ];
464        let bb = bounding_box_for_stroke_path(&ops, 2.0, 0.5).unwrap();
465        assert!((bb.left - (-1.0)).abs() < 0.01);
466        assert!((bb.right - 11.0).abs() < 0.01);
467    }
468
469    #[test]
470    fn test_stroke_bounding_box_empty_path() {
471        assert_eq!(bounding_box_for_stroke_path(&[], 1.0, 10.0), None);
472    }
473
474    #[test]
475    fn test_get_rect_detects_axis_aligned_rect() {
476        let ops = vec![
477            PathOp::MoveTo { x: 10.0, y: 20.0 },
478            PathOp::LineTo { x: 50.0, y: 20.0 },
479            PathOp::LineTo { x: 50.0, y: 80.0 },
480            PathOp::LineTo { x: 10.0, y: 80.0 },
481            PathOp::LineTo { x: 10.0, y: 20.0 },
482            PathOp::Close,
483        ];
484        let r = rect_if_axis_aligned(&ops).unwrap();
485        assert!((r.left - 10.0).abs() < 0.01);
486        assert!((r.bottom - 20.0).abs() < 0.01);
487        assert!((r.right - 50.0).abs() < 0.01);
488        assert!((r.top - 80.0).abs() < 0.01);
489    }
490
491    #[test]
492    fn test_get_rect_rejects_non_rect_path() {
493        // Diagonal line — not a rect
494        let ops = vec![
495            PathOp::MoveTo { x: 0.0, y: 0.0 },
496            PathOp::LineTo { x: 10.0, y: 5.0 },
497            PathOp::LineTo { x: 20.0, y: 0.0 },
498            PathOp::LineTo { x: 10.0, y: -5.0 },
499            PathOp::LineTo { x: 0.0, y: 0.0 },
500        ];
501        assert_eq!(rect_if_axis_aligned(&ops), None);
502    }
503
504    #[test]
505    fn test_get_rect_rejects_triangle() {
506        let ops = vec![
507            PathOp::MoveTo { x: 0.0, y: 0.0 },
508            PathOp::LineTo { x: 10.0, y: 0.0 },
509            PathOp::LineTo { x: 5.0, y: 10.0 },
510            PathOp::Close,
511        ];
512        assert_eq!(rect_if_axis_aligned(&ops), None);
513    }
514
515    // --- Upstream ports ---
516
517    /// Upstream: TEST(CFXPath, BasicTest)
518    #[test]
519    fn test_basic_path_operations() {
520        // AppendRect equivalent: 5 points (MoveTo + 3 LineTo + closing LineTo)
521        let ops = vec![
522            PathOp::MoveTo { x: 1.0, y: 2.0 },
523            PathOp::LineTo { x: 3.0, y: 2.0 },
524            PathOp::LineTo { x: 3.0, y: 5.0 },
525            PathOp::LineTo { x: 1.0, y: 5.0 },
526            PathOp::LineTo { x: 1.0, y: 2.0 },
527        ];
528        assert!(is_rect(&ops));
529        let rect = rect_if_axis_aligned(&ops).unwrap();
530        assert_eq!(
531            (
532                rect.left as f32,
533                rect.bottom as f32,
534                rect.right as f32,
535                rect.top as f32
536            ),
537            (1.0, 2.0, 3.0, 5.0)
538        );
539        let bb = bounding_box(&ops).unwrap();
540        assert_eq!(
541            (
542                bb.left as f32,
543                bb.bottom as f32,
544                bb.right as f32,
545                bb.top as f32
546            ),
547            (1.0, 2.0, 3.0, 5.0)
548        );
549
550        // Empty path
551        let empty: Vec<PathOp> = vec![];
552        assert!(!is_rect(&empty));
553        assert_eq!(bounding_box(&empty), None);
554
555        // 4 points without close makes a rect
556        let ops = vec![
557            PathOp::MoveTo { x: 0.0, y: 0.0 },
558            PathOp::LineTo { x: 0.0, y: 1.0 },
559            PathOp::LineTo { x: 1.0, y: 1.0 },
560            PathOp::LineTo { x: 1.0, y: 0.0 },
561            PathOp::LineTo { x: 0.0, y: 0.0 },
562        ];
563        assert!(is_rect(&ops));
564        let rect = rect_if_axis_aligned(&ops).unwrap();
565        assert_eq!(
566            (
567                rect.left as f32,
568                rect.bottom as f32,
569                rect.right as f32,
570                rect.top as f32
571            ),
572            (0.0, 0.0, 1.0, 1.0)
573        );
574
575        // 4 points with close also a rect
576        let ops = vec![
577            PathOp::MoveTo { x: 0.0, y: 0.0 },
578            PathOp::LineTo { x: 0.0, y: 1.0 },
579            PathOp::LineTo { x: 1.0, y: 1.0 },
580            PathOp::LineTo { x: 1.0, y: 0.0 },
581            PathOp::LineTo { x: 0.0, y: 0.0 },
582            PathOp::Close,
583        ];
584        assert!(is_rect(&ops));
585    }
586
587    /// Upstream: TEST(CFXPath, ShearTransform)
588    ///
589    /// Tests that a sheared rectangle is no longer detected as a rect.
590    /// rpdfium doesn't have an in-place Transform method on paths, so we
591    /// apply the shear matrix manually and verify bounding box behavior.
592    #[test]
593    fn test_shear_transform_breaks_rect() {
594        // Original rect
595        let ops = vec![
596            PathOp::MoveTo { x: 1.0, y: 2.0 },
597            PathOp::LineTo { x: 3.0, y: 2.0 },
598            PathOp::LineTo { x: 3.0, y: 5.0 },
599            PathOp::LineTo { x: 1.0, y: 5.0 },
600            PathOp::LineTo { x: 1.0, y: 2.0 },
601        ];
602        assert!(is_rect(&ops));
603
604        // Apply shear matrix (1, 2, 0, 1, 0, 0): x' = x, y' = 2x + y
605        let sheared: Vec<PathOp> = ops
606            .iter()
607            .map(|op| match op {
608                PathOp::MoveTo { x, y } => PathOp::MoveTo {
609                    x: *x,
610                    y: 2.0 * x + y,
611                },
612                PathOp::LineTo { x, y } => PathOp::LineTo {
613                    x: *x,
614                    y: 2.0 * x + y,
615                },
616                _ => op.clone(),
617            })
618            .collect();
619
620        // Sheared rect is no longer axis-aligned
621        assert!(!is_rect(&sheared));
622        assert!(rect_if_axis_aligned(&sheared).is_none());
623
624        // Bounding box of sheared path
625        let bb = bounding_box(&sheared).unwrap();
626        assert_eq!(
627            (
628                bb.left as f32,
629                bb.bottom as f32,
630                bb.right as f32,
631                bb.top as f32
632            ),
633            (1.0, 4.0, 3.0, 11.0)
634        );
635    }
636
637    /// Upstream: TEST(CFXPath, Hexagon)
638    #[test]
639    fn test_hexagon_not_rect() {
640        let ops = vec![
641            PathOp::MoveTo { x: 1.0, y: 0.0 },
642            PathOp::LineTo { x: 2.0, y: 0.0 },
643            PathOp::LineTo { x: 3.0, y: 1.0 },
644            PathOp::LineTo { x: 2.0, y: 2.0 },
645            PathOp::LineTo { x: 1.0, y: 2.0 },
646            PathOp::LineTo { x: 0.0, y: 1.0 },
647        ];
648        assert!(!is_rect(&ops));
649        assert!(rect_if_axis_aligned(&ops).is_none());
650        let bb = bounding_box(&ops).unwrap();
651        assert_eq!(
652            (
653                bb.left as f32,
654                bb.bottom as f32,
655                bb.right as f32,
656                bb.top as f32
657            ),
658            (0.0, 0.0, 3.0, 2.0)
659        );
660
661        // With Close -- still not a rect
662        let mut ops_closed = ops.clone();
663        ops_closed.push(PathOp::Close);
664        assert!(!is_rect(&ops_closed));
665
666        // Close is idempotent for bounding box
667        let bb2 = bounding_box(&ops_closed).unwrap();
668        assert_eq!((bb2.left as f32, bb2.bottom as f32), (0.0, 0.0));
669        assert_eq!((bb2.right as f32, bb2.top as f32), (3.0, 2.0));
670
671        // Hexagon with same start/end point is still not a rect
672        let ops_loop = vec![
673            PathOp::MoveTo { x: 1.0, y: 0.0 },
674            PathOp::LineTo { x: 2.0, y: 0.0 },
675            PathOp::LineTo { x: 3.0, y: 1.0 },
676            PathOp::LineTo { x: 2.0, y: 2.0 },
677            PathOp::LineTo { x: 1.0, y: 2.0 },
678            PathOp::LineTo { x: 0.0, y: 1.0 },
679            PathOp::LineTo { x: 1.0, y: 0.0 },
680        ];
681        assert!(!is_rect(&ops_loop));
682    }
683
684    /// Upstream: TEST(CFXPath, Append)
685    ///
686    /// Tests appending paths. rpdfium uses Vec<PathOp> rather than a Path
687    /// object, so append is just Vec::extend.
688    #[test]
689    fn test_path_append() {
690        let mut path = vec![PathOp::MoveTo { x: 5.0, y: 6.0 }];
691        assert_eq!(path.len(), 1);
692
693        // Append empty path
694        let empty: Vec<PathOp> = vec![];
695        path.extend(empty.iter().cloned());
696        assert_eq!(path.len(), 1);
697
698        // Append self (clone first to avoid borrow conflict)
699        let snapshot = path.clone();
700        path.extend(snapshot);
701        assert_eq!(path.len(), 2);
702        assert_eq!(path[0], PathOp::MoveTo { x: 5.0, y: 6.0 });
703        assert_eq!(path[1], PathOp::MoveTo { x: 5.0, y: 6.0 });
704
705        // Append with transform: apply scale matrix (1,0,0,2,60,70)
706        let transformed: Vec<PathOp> = path
707            .iter()
708            .map(|op| match op {
709                PathOp::MoveTo { x, y } => PathOp::MoveTo {
710                    x: 1.0 * x + 60.0,
711                    y: 2.0 * y + 70.0,
712                },
713                PathOp::LineTo { x, y } => PathOp::LineTo {
714                    x: 1.0 * x + 60.0,
715                    y: 2.0 * y + 70.0,
716                },
717                other => other.clone(),
718            })
719            .collect();
720        path.extend(transformed);
721        assert_eq!(path.len(), 4);
722        assert_eq!(path[2], PathOp::MoveTo { x: 65.0, y: 82.0 });
723        assert_eq!(path[3], PathOp::MoveTo { x: 65.0, y: 82.0 });
724    }
725}