mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 12:25:17 +00:00
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:
co-authored by
Claude Opus 5.5
parent
f76dbb9e8c
commit
7bcd89c816
@@ -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);
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+72
-3
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user