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.
This commit is contained in:
Hamza
2026-10-19 00:29:13 +05:00
parent 932bf9c6bf
commit 645a8ca205
2 changed files with 67 additions and 5 deletions
+48
View File
@@ -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<u8> {
+19 -5
View File
@@ -451,11 +451,25 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option<Rect> {
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<Rect> {
}
}
if corners[0] && corners[1] && corners[2] && corners[3] {
if !off_corner && corners[0] && corners[1] && corners[2] && corners[3] {
Some(bbox)
} else {
None