From 631ca00bdd4ad890f7ab8f1e2a115a6b9d8aa3ee Mon Sep 17 00:00:00 2001 From: Rahul Ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:50:30 +0530 Subject: [PATCH] The image scan skips a path segment before any m instead of panicking (#3581) A content stream that uses l, c, v, y or h before any m or re has no subpath to extend. The edit-side image scan (crates/edit/src/images.rs) handed the operators straight to BezPath's line_to/curve_to/close_path, which panic on an empty path by contract, so object_list, page_images, image_hit and the Office export's image walk all crashed on such a page, while the renderer, text extraction, MuPDF and pdftotext skip the malformed segment and render the rest. The scan now skips the segment when the path has no elements, mirroring the v arm's existing guard and kurbo's own condition (debug_assert on !is_empty). A path started with m or re extends and closes exactly as before. A clip set by a stray segment (W n on an empty path) takes a zero bounding box, which kurbo defines as Rect::default, not a panic. Regression test: each of the five segment operators before any m, then a well-formed m l h and a re W n clip, all before an image Do - the image is still listed in every case. Verified against kurbo 0.13.1's source (line_to/curve_to/close_path debug_assert on empty; bounding_box unwrap_or_default) and the issue's repro; no Rust toolchain on this machine, so cargo test was not run here - saying so plainly for the reviewer. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/edit/src/images.rs | 22 ++++++++++++++++++---- crates/edit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e90d8e4f15..6c67bf917c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -165,12 +165,19 @@ impl ImageState { } } b"l" => { - if let Some([x, y]) = op.nums::<2>() { + // A segment before any m (or re) has no subpath to extend: BezPath's line_to and + // friends panic on an empty path by contract, so the segment is skipped, as the + // renderer, MuPDF and pdftotext do (#3581). + if let Some([x, y]) = op.nums::<2>() + && self.path.elements().last().is_some() + { self.path.line_to(self.point(x, y)); } } b"c" => { - if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() { + if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() + && self.path.elements().last().is_some() + { self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); } } @@ -182,12 +189,19 @@ impl ImageState { } } b"y" => { - if let Some([x1, y1, x2, y2]) = op.nums::<4>() { + if let Some([x1, y1, x2, y2]) = op.nums::<4>() + && self.path.elements().last().is_some() + { let end = self.point(x2, y2); self.path.curve_to(self.point(x1, y1), end, end); } } - b"h" => self.path.close_path(), + b"h" => { + // ClosePath with no subpath panics by the same contract (#3581). + if self.path.elements().last().is_some() { + self.path.close_path(); + } + } b"re" => { if let Some([x, y, w, h]) = op.nums::<4>() { self.path.move_to(self.point(x, y)); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c66..9bd3b86113 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1964,6 +1964,25 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +/// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used +/// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, +/// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). +/// The segment is now skipped, as the renderer, MuPDF and pdftotext do, and the page's images are +/// still listed. +#[test] +fn image_scan_skips_a_path_segment_before_any_m() { + for bad in ["1 1 l S", "1 1 2 2 3 3 c S", "1 1 2 2 4 4 y S", "1 1 2 2 3 3 v S", "h S"] { + let content = format!("q {bad} Q 100 100 200 100 cm /Im0 Do"); + let doc = image_page_with(&content); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "{bad}: the image is still listed"); + } + // A path started with m (or re) still extends and closes as before. + let doc = image_page_with("q 0 0 m 1 1 l h S Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); + let doc = image_page_with("q 10 10 20 20 re W n Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + /// The box of the first `re` on the page in user space, after the `cm`s before it. fn first_rect_in_user_space(doc: &Document) -> [f64; 4] { let mut ctm = pdfcraft_content::Matrix::IDENTITY;