From 645a8ca205e845eb07aaf1d7d8aaab809011503e Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 00:29:13 +0500 Subject: [PATCH] Render: a five-point polyline is no longer painted as its bounding box `path_as_rect` decided a path was a rectangle from its element count and from the four corners of its bounding box being covered. Its comment names the shape it means to match -- one MoveTo, three LineTo, one ClosePath -- but `len() != 5` also admits one MoveTo and four LineTo, a five-point open polyline, and covering the corners says nothing about the points between them. Both call sites then paint `device.draw_rect(bbox)`, so such a path was drawn as its own bounding box. Rendered through `pdfcraft-cli render`, an open zigzag whose ends reach the corners of its bounding box 80 38 m 80 21.75 l 60.571 29.875 l 40 21.75 l 40 38 l S lost its middle peak and gained a straight edge the file does not contain: no dark pixel near the peak, and 36 along the bounding box's top. A six-point zigzag and a real rectangle both drew correctly, which is why this shows on some art and not the rest -- and `s` escapes it because its ClosePath makes six elements. A point that is not a corner now rejects the path. A rectangle still matches whether written `m l l l` and closed or `m l l l l` back to its first corner, since every one of its points is a corner. The same mistake also discarded a clip of that shape, through `push_clip_path`, which this fixes too. Found from craft-libs' ATTRIBUTION note that the shared reader centralizes the patched Hayro trees from PdfCraft: the same flaw is in craft-libs, sent there as storytold/craft-libs#175. --- crates/render/src/raster.rs | 48 +++++++++++++++++++++++++++ vendor/hayro-interpret/src/context.rs | 24 +++++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b2127894f..b502d55ad 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4298,6 +4298,54 @@ trailer << /Root 1 0 R >> assert!(grey[0] == grey[1] && grey[1] == grey[2] && grey[0] < 255, "the image covers the page: {grey:?}"); } + /// A five-point open polyline is not a rectangle. `path_as_rect` took any five-element path + /// whose points covered the four corners of its bounding box for that box, and a `MoveTo` with + /// four `LineTo` is five elements, so an open zigzag stroked with `S` was painted as its + /// bounding rectangle: the peak between its ends vanished and the open side came out closed. + #[test] + fn a_five_point_polyline_is_not_painted_as_its_bounding_box() { + // The ends and the points beside them sit on the four corners of the bounding box; the + // peak at (20, 20) lies between them and is on no corner. + let content = b"0 0 0 RG 2 w 30 30 m 30 10 l 20 20 l 10 10 l 10 30 l S".to_vec(); + let pdf = one_page_pdf(&content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the polyline must render"); + assert!(page.error.is_none(), "{:?}", page.error); + // Device y counts down the 40 pt page, so the two ends at y = 30 are on row 10. + let dark = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] < 128 + }; + assert!((17..=23).any(|x| (17..=23).any(|y| dark(x, y))), "the peak between the ends draws"); + assert!(!(14..=26).any(|x| dark(x, 10)), "the side the path leaves open stays open"); + } + + /// A rectangle still takes the rectangle route, written either way: `m l l l` and closed, or + /// `m l l l l` back to its first corner. + #[test] + fn a_rectangle_is_still_painted_as_a_rectangle() { + for content in [&b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l f"[..], &b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l 10 10 l f"[..]] { + let pdf = one_page_pdf(content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the rectangle must render"); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] + }; + assert!(pixel(20, 20) < 128, "the rectangle is filled"); + assert_eq!(pixel(5, 5), 255, "and nothing outside it is"); + } + } + /// A one-page PDF whose content is the stream `stream`, with the entries `dict` in its /// dictionary. fn one_page_pdf(stream: &[u8], dict: &str) -> Vec { diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index d6772baa1..3a06a8873 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -451,11 +451,25 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option { let (min_x, min_y, max_x, max_y) = (bbox.min_x(), bbox.min_y(), bbox.max_x(), bbox.max_y()); let mut corners = [false; 4]; + // PdfCraft patch: a point that is not a corner means this is not a rectangle. The length + // check above admits one MoveTo and four LineTo as well as the intended one MoveTo, three + // LineTo and one ClosePath, and covering the four corners says nothing about the points + // between them, so a five-point polyline whose ends reach every corner of its own bounding + // box was painted as that box (and a clip of that shape was discarded). + let mut off_corner = false; + let mut check_point = |p: Point| { - corners[0] |= p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(min_y); - corners[1] |= p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(max_y); - corners[2] |= p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(min_y); - corners[3] |= p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(max_y); + let hits = [ + p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(min_y), + p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(max_y), + p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(min_y), + p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(max_y), + ]; + off_corner |= !hits.iter().any(|hit| *hit); + + for (corner, hit) in corners.iter_mut().zip(hits) { + *corner |= hit; + } }; for (idx, el) in path.elements().iter().enumerate() { @@ -474,7 +488,7 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option { } } - if corners[0] && corners[1] && corners[2] && corners[3] { + if !off_corner && corners[0] && corners[1] && corners[2] && corners[3] { Some(bbox) } else { None