M2: zero-area fills and clips mark the pixels they touch (ISO 32000-2 scan conversion)

A rectangle with no height or no width encloses no area, so the
rasteriser marked nothing for it. As a clip (`10 10 100 0 re W n`) it
hid everything painted after it; as a fill it fell back to a
thinnest-line stroke that half-covered two rows when the line lay on a
pixel boundary.

ISO 32000-2 §10.7.4 paints every pixel whose half-open square the shape
intersects, "no matter how small the intersection is" ("A zero-width or
zero-height rectangle paints a line 1 pixel wide"), and a clipping
region is "the set of pixels that would be included by a fill
operation".

hayro-interpret patch (65), `degenerate_fill_area`: a fill or clip whose
path has no width or no height (but not both) gets the device pixels the
line touches - whole pixels when it runs along a device axis, a band one
device pixel wide when a rotated CTM turns it. Points, and paths with
area however thin, paint as before.

Tests: crates/render/tests/it/zero_area_fill_clip.rs (5 of 8 fail with
the patch reverted; 9 mutants of the patch each fail a test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410
2026-10-30 14:11:31 +08:00
co-authored by Claude Opus 5.5
parent f76dbb9e8c
commit 7bcd89c816
4 changed files with 274 additions and 4 deletions
+1
View File
@@ -15,3 +15,4 @@ mod stroke_degenerate_cubic;
mod substitute_widths;
mod tiling_cell_placement;
mod type3_text_clip;
mod zero_area_fill_clip;
@@ -0,0 +1,200 @@
//! Zero-area fills and clips mark the pixels they touch (ISO 32000-2 §10.7.4: "A zero-width or
//! zero-height rectangle paints a line 1 pixel wide", and "the clipping region consists of the
//! set of pixels that would be included by a fill operation").
//!
//! Every page is 200 × 200 pt rendered at scale 1, so user `(x, y)` lands on device
//! `(x, 200 - y)`: a line at user `y = 50` lies on the boundary between device rows 149 and
//! 150, and by the half-open rule marks row 150.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
const SIZE: u32 = 200;
fn build_pdf(content: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {SIZE} {SIZE}] /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::with_capacity(objs.len());
for (i, body) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
let size = objs.len() + 1;
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for off in offs {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
/// The page drawn by `content`, as RGBA rows of `SIZE` pixels.
fn render(content: &str) -> Vec<u8> {
let pdf = Arc::new(build_pdf(content));
let mut renderer = PageRenderer::new(pdf, RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "render error: {:?}", page.error);
assert_eq!((page.width, page.height), (SIZE, SIZE));
page.rgba.to_vec()
}
fn px(rgba: &[u8], x: u32, y: u32) -> [u8; 3] {
let i = ((y * SIZE + x) * 4) as usize;
[rgba[i], rgba[i + 1], rgba[i + 2]]
}
fn is_red(p: [u8; 3]) -> bool {
p[0] > 240 && p[1] < 15 && p[2] < 15
}
fn is_white(p: [u8; 3]) -> bool {
p.iter().all(|&c| c > 250)
}
/// The red pixels of the page, as `(x, y)`.
fn red_pixels(rgba: &[u8]) -> Vec<(u32, u32)> {
let mut red = Vec::new();
for y in 0..SIZE {
for x in 0..SIZE {
if is_red(px(rgba, x, y)) {
red.push((x, y));
}
}
}
red
}
/// The pixels that are neither white nor full red: partly covered ones.
fn tinted_pixels(rgba: &[u8]) -> usize {
let mut n = 0;
for y in 0..SIZE {
for x in 0..SIZE {
let p = px(rgba, x, y);
if !is_red(p) && !is_white(p) {
n += 1;
}
}
}
n
}
/// Asserts the page is exactly a full-red run of device pixels `x0..x1` × `y0..y1` on white.
fn assert_only_red_run(rgba: &[u8], (x0, x1): (u32, u32), (y0, y1): (u32, u32), what: &str) {
let mut expected = Vec::new();
for y in y0..y1 {
for x in x0..x1 {
expected.push((x, y));
}
}
assert_eq!(red_pixels(rgba), expected, "{what}: the red pixels");
assert_eq!(tinted_pixels(rgba), 0, "{what}: partly covered pixels");
}
const FLOOD: &str = "1 0 0 rg 0 0 200 200 re f";
/// A fill that covers the whole page under any of the CTMs below.
const BIG_FLOOD: &str = "1 0 0 rg -400 -400 800 800 re f";
#[test]
fn zero_height_clip_shows_the_fill_as_one_device_row() {
// User y = 50 is device y = 150, a pixel boundary: row 150 and nothing else.
let rgba = render(&format!("q 10 50 100 0 re W n {FLOOD} Q"));
assert_only_red_run(&rgba, (10, 110), (150, 151), "clip on a pixel boundary");
// Off the boundary (device y = 149.5) the line marks the row it runs through.
let rgba = render(&format!("q 10 50.5 100 0 re W n {FLOOD} Q"));
assert_only_red_run(&rgba, (10, 110), (149, 150), "clip inside a row");
// Inside a translated CTM, as the line is usually drawn.
let rgba = render(&format!("q 1 0 0 1 20 30 cm 10 10 100 0 re W n {FLOOD} Q"));
assert_only_red_run(&rgba, (30, 130), (160, 161), "clip under a translation");
}
#[test]
fn zero_width_clip_shows_the_fill_as_one_device_column() {
let rgba = render(&format!("q 50 10 0 100 re W n {FLOOD} Q"));
assert_only_red_run(&rgba, (50, 51), (90, 190), "zero-width clip");
}
#[test]
fn zero_height_and_zero_width_fills_paint_one_pixel_lines() {
let rgba = render("1 0 0 rg 10 50 100 0 re f");
assert_only_red_run(&rgba, (10, 110), (150, 151), "zero-height fill");
let rgba = render("1 0 0 rg 50 10 0 100 re f");
assert_only_red_run(&rgba, (50, 51), (90, 190), "zero-width fill");
// The same line drawn as a fill and seen through a clip marks the same pixels.
let fill = render("1 0 0 rg 10 50.5 100 0 re f");
let clip = render(&format!("q 10 50.5 100 0 re W n {FLOOD} Q"));
assert_eq!(red_pixels(&fill), red_pixels(&clip));
}
#[test]
fn a_quarter_turn_maps_a_zero_height_clip_to_one_device_column() {
// `0 1 -1 0 100 20 cm` turns user x into device -y: the line runs up column 100.
let rgba = render(&format!("q 0 1 -1 0 100 20 cm 10 0 100 0 re W n {BIG_FLOOD} Q"));
assert_only_red_run(&rgba, (100, 101), (70, 170), "quarter-turned clip");
}
#[test]
fn a_rotated_zero_height_clip_and_fill_show_a_one_pixel_band() {
// 45 degrees: the line runs diagonally, so it shows as a band about one pixel wide.
let clip = render(&format!("q 0.70710678 0.70710678 -0.70710678 0.70710678 50 50 cm 0 0 100 0 re W n {BIG_FLOOD} Q"));
let fill = render("q 0.70710678 0.70710678 -0.70710678 0.70710678 50 50 cm 1 0 0 rg 0 0 100 0 re f Q");
for (rgba, what) in [(&clip, "clip"), (&fill, "fill")] {
// Along the diagonal from device (50, 150) to (120.7, 79.3), the pixel the line crosses
// corner to corner in each column is mostly red, and the pixels well off it are white.
for step in 5..65 {
let (x, y) = (50 + step, 149 - step);
let p = px(rgba, x, y);
assert!(p[0] > 240 && p[1] < 160, "{what}: pixel ({x}, {y}) on the line is {p:?}");
assert!(is_white(px(rgba, x + 3, y)), "{what}: pixel off the line");
assert!(is_white(px(rgba, x - 3, y)), "{what}: pixel off the line");
}
// The band is one pixel wide: its ink adds up to its length, 100 pixels.
let ink: f64 = rgba.as_chunks::<4>().0.iter().map(|p| 1.0 - f64::from(p[1]) / 255.0).sum();
assert!((90.0..=110.0).contains(&ink), "{what}: ink {ink}");
}
}
#[test]
fn a_thin_rectangle_with_area_keeps_its_partial_coverage() {
// 0.3 pt high is not degenerate: it covers 30 % of device row 149 and is antialiased as
// before, not widened to a whole pixel.
let rgba = render("1 0 0 rg 10 50.2 100 0.3 re f");
assert!(red_pixels(&rgba).is_empty(), "a 0.3 pt rectangle is not painted solid");
for x in 10..110 {
let p = px(&rgba, x, 149);
assert!(p[0] > 240, "row 149 at {x}: {p:?}");
assert!((150..=210).contains(&p[1]), "row 149 at {x} is 30 % covered: {p:?}");
assert!(is_white(px(&rgba, x, 148)) && is_white(px(&rgba, x, 150)));
}
// The same rectangle as a clip keeps the same partial coverage.
let clip = render(&format!("q 10 50.2 100 0.3 re W n {FLOOD} Q"));
assert_eq!(clip, rgba);
}
#[test]
fn a_zero_area_clip_with_nothing_painted_on_it_stays_blank() {
// The fill misses the line the clip keeps.
let rgba = render("q 10 50 100 0 re W n 1 0 0 rg 0 100 200 100 re f Q");
assert!(rgba.as_chunks::<4>().0.iter().all(|p| is_white([p[0], p[1], p[2]])), "page not blank");
// Nothing painted at all.
let rgba = render("q 10 50 100 0 re W n Q");
assert!(rgba.as_chunks::<4>().0.iter().all(|p| is_white([p[0], p[1], p[2]])), "page not blank");
// A clip that is a point keeps nothing, as before.
let rgba = render(&format!("q 10 50 0 0 re W n {FLOOD} Q"));
assert!(red_pixels(&rgba).is_empty() && tinted_pixels(&rgba) == 0, "a point clip shows the fill");
// So does a point a little off zero both ways.
let rgba = render(&format!("q 10 50 0.001 0.001 re W n {FLOOD} Q"));
assert!(red_pixels(&rgba).is_empty() && tinted_pixels(&rgba) == 0, "a tiny clip shows the fill");
}
#[test]
fn the_clip_is_restored_after_q() {
let rgba = render(&format!("q 10 50 100 0 re W n Q {FLOOD}"));
assert_eq!(red_pixels(&rgba).len(), (SIZE * SIZE) as usize);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+72 -3
View File
@@ -3,7 +3,7 @@ use crate::context::{Context, path_as_rect};
use crate::device::Device;
use crate::util::{BezPathExt, Float32Ext};
use crate::{FillRule, Paint, PathDrawMode, StrokeProps};
use kurbo::{BezPath, Cap, Join, PathEl};
use kurbo::{Affine, BezPath, Cap, Join, PathEl, Point, Rect, Shape, Vec2};
use smallvec::smallvec;
pub(crate) fn fill_path<'a>(
@@ -43,11 +43,68 @@ pub(crate) fn apply_pending_clip<'a>(context: &mut Context<'a>, device: &mut imp
if let Some(rule) = context.clip_mut().take()
&& !context.path().elements().is_empty()
{
let clip_path = context.get().ctm * context.path().clone();
context.push_clip_path(clip_path, rule, device);
let ctm = context.get().ctm;
// PdfCraft patch (65): a clip that encloses no area keeps the pixels a fill of it marks
// (see `degenerate_fill_area`), as ISO 32000-2 §10.7.4 says of clipping: "the clipping
// region consists of the set of pixels that would be included by a fill operation".
match degenerate_fill_area(context.path().fast_bounding_box(), ctm) {
Some(area) => context.push_clip_path(area, FillRule::NonZero, device),
None => {
let clip_path = ctm * context.path().clone();
context.push_clip_path(clip_path, rule, device);
}
}
}
}
/// PdfCraft patch (65): the device-space area a fill marks for a path whose bounding box `bbox`
/// (user space) has no width or no height, but not both, under `ctm`. ISO 32000-2 §10.7.4 paints
/// "any pixel whose half-open square region intersects the shape, no matter how small the
/// intersection is", and its example says "A zero-width or zero-height rectangle paints a line
/// 1 pixel wide". Such a path encloses no area, so the rasteriser marks nothing: the fill used
/// to fall back to a thinnest-line stroke along the box (half-covering two rows when the line
/// fell on a pixel boundary), and a clip of it hid everything painted after it.
///
/// When the line lies along a device axis (less than a pixel across the other way), the area is
/// the run of whole pixels it touches, by the half-open rule: from the floor of its low ends to
/// the ceiling of its high ends, at least one pixel either way. Otherwise (a rotated or skewed
/// `ctm`) it is a band one device pixel wide centred on the line, like the thinnest line of
/// §8.4.3.2. `None` for a box with area, a point (both sides zero), a non-finite box, or a `ctm`
/// that collapses the line to a point: those paint as before.
pub(crate) fn degenerate_fill_area(bbox: Rect, ctm: Affine) -> Option<BezPath> {
let flat_x = (bbox.width() as f32).is_nearly_zero();
let flat_y = (bbox.height() as f32).is_nearly_zero();
if flat_x == flat_y {
return None;
}
let a = ctm * Point::new(bbox.x0, bbox.y0);
let b = ctm * Point::new(bbox.x1, bbox.y1);
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) {
return None;
}
let along = b - a;
let length = along.hypot();
if !length.is_finite() || length <= 0.0 {
return None;
}
let device = Rect::from_points(a, b);
if device.width() < 1.0 || device.height() < 1.0 {
let x0 = device.x0.floor();
let y0 = device.y0.floor();
let x1 = device.x1.ceil().max(x0 + 1.0);
let y1 = device.y1.ceil().max(y0 + 1.0);
return Some(Rect::new(x0, y0, x1, y1).to_path(0.1));
}
let half = Vec2::new(-along.y, along.x) * (0.5 / length);
let mut band = BezPath::new();
band.move_to(a + half);
band.line_to(b + half);
band.line_to(b - half);
band.line_to(a - half);
band.close_path();
Some(band)
}
pub(crate) fn close_path(context: &mut Context<'_>) {
// This is necessary to prevent artifacts (see for example issue 157),
// but it does cause some weird lines (maybe conflation artifacts) in
@@ -79,6 +136,18 @@ pub(crate) fn fill_path_impl<'a>(
// pdf.js issue 4260: Replace zero-sized paths with a small stroke instead.
let bbox = path.fast_bounding_box();
// PdfCraft patch (65): see `degenerate_fill_area`. Painted with the non-zero rule: the
// area is one simple shape, whatever rule the path was filled with.
if let Some(area) = degenerate_fill_area(bbox, base_transform) {
let draw_mode = PathDrawMode::Fill(FillRule::NonZero);
if let Some(rect) = path_as_rect(&area) {
device.draw_rect(&rect, Affine::IDENTITY, &paint, &draw_mode);
} else {
device.draw_path(&area, Affine::IDENTITY, &paint, &draw_mode);
}
return;
}
match (
(bbox.width() as f32).is_nearly_zero(),
(bbox.height() as f32).is_nearly_zero(),