mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 17:34:06 -06:00
Merge #4433: M2: zero-area fills and clips mark the pixels they touch (view.render-pages)
# Conflicts: # vendor/README.md # vendor/hayro-interpret/src/interpret/path.rs
This commit is contained in:
@@ -23,3 +23,4 @@ mod symbol_mt_builtin_encoding;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
mod widgets_above_annotations;
|
||||
mod zero_area_fill_clip;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! 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").
|
||||
//!
|
||||
//! Which row (or column) a line on a pixel boundary marks is not left open. §10.7.4, "Scan
|
||||
//! conversion rules", in device space:
|
||||
//!
|
||||
//! > Pixel boundaries always fall on integer coordinates in device space. A pixel is a square
|
||||
//! > region identified by the location of its corner with minimum horizontal and vertical
|
||||
//! > coordinates. The region is half-open, meaning that it includes its lower but not its upper
|
||||
//! > boundaries. More precisely, for any point whose real-number coordinates are (x, y), let
|
||||
//! > i = floor(x) and j = floor(y). The pixel that contains this point is the one identified as
|
||||
//! > (i, j).
|
||||
//!
|
||||
//! > Like pixels, shapes to be painted by filling (8.5.3.3, "Filling") and stroking (8.5.3.2,
|
||||
//! > "Stroking") operations are also treated as half-open regions that include the boundaries
|
||||
//! > along their "floor" sides, but not along their "ceiling" sides.
|
||||
//!
|
||||
//! > A shape shall be scan-converted by painting any pixel whose half-open square region
|
||||
//! > intersects the shape, no matter how small the intersection is.
|
||||
//!
|
||||
//! So a line at device y = 150 lies in pixel row floor(150) = 150 and not in row 149, whose
|
||||
//! region stops short of 150; a line from device x = 10 to x = 110 marks columns 10 to 109, as
|
||||
//! its ceiling end at 110 is not part of it. Off a boundary, a line at y = 149.5 marks row
|
||||
//! floor(149.5) = 149. The rule is stated in device space, whose y axis here points down the
|
||||
//! page, so row 150 is the one just below the line as the page is seen.
|
||||
//!
|
||||
//! 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));
|
||||
}
|
||||
|
||||
/// Each of the four cases (zero-height and zero-width, fill and clip) on a pixel boundary and
|
||||
/// off one, by the half-open rule quoted above: a line on a boundary marks the row or column on
|
||||
/// its floor side, one off it marks the row or column it runs through, and its ends mark every
|
||||
/// pixel they reach into but not one they only meet at the ceiling side.
|
||||
#[test]
|
||||
fn zero_height_and_zero_width_fills_and_clips_on_and_off_a_pixel_boundary() {
|
||||
let cases = [
|
||||
// (the line, its device columns, its device rows, what)
|
||||
("10 50 100 0 re", (10, 110), (150, 151), "zero-height on a boundary"),
|
||||
("10 50.5 100 0 re", (10, 110), (149, 150), "zero-height off a boundary"),
|
||||
("10.5 50.5 99 0 re", (10, 110), (149, 150), "zero-height, ends inside pixels"),
|
||||
("50 10 0 100 re", (50, 51), (90, 190), "zero-width on a boundary"),
|
||||
("50.5 10 0 100 re", (50, 51), (90, 190), "zero-width off a boundary"),
|
||||
("50.5 10.5 0 99 re", (50, 51), (90, 190), "zero-width, ends inside pixels"),
|
||||
];
|
||||
for (line, columns, rows, what) in cases {
|
||||
let fill = render(&format!("1 0 0 rg {line} f"));
|
||||
assert_only_red_run(&fill, columns, rows, &format!("{what}: fill"));
|
||||
let clip = render(&format!("q {line} W n {FLOOD} Q"));
|
||||
assert_only_red_run(&clip, columns, rows, &format!("{what}: 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_rectangle_scaled_up_to_whole_pixels_keeps_its_area() {
|
||||
// 1/1024 pt high is under the rasteriser's "nearly zero" threshold, but the rectangle has
|
||||
// area: scaled 4096 times vertically it covers device y 164..168, four whole rows, and is
|
||||
// painted as the rectangle it is, not as a line across its bounding box.
|
||||
let fill = render("q 1 0 0 4096 0 0 cm 1 0 0 rg 10 0.0078125 100 0.0009765625 re f Q");
|
||||
assert_only_red_run(&fill, (10, 110), (164, 168), "scaled-up thin fill");
|
||||
let clip = render(&format!("q 1 0 0 4096 0 0 cm 10 0.0078125 100 0.0009765625 re W n {FLOOD} Q"));
|
||||
assert_only_red_run(&clip, (10, 110), (164, 168), "scaled-up thin clip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slanted_zero_height_line_crossing_a_row_marks_each_row_only_where_it_runs() {
|
||||
// A slight skew lifts the line from device y 149.52 at x = 10 to 148.74 at x = 110: under a
|
||||
// pixel high in all, but crossing the boundary between rows 148 and 149 near x = 75. Each
|
||||
// row is marked where the line runs through it, not along the whole length.
|
||||
let skew = "1 0.008 0 1 0 0 cm";
|
||||
let fill = render(&format!("q {skew} 1 0 0 rg 10 50.4 100 0 re f Q"));
|
||||
let clip = render(&format!("q {skew} 10 50.4 100 0 re W n {FLOOD} Q"));
|
||||
for (rgba, what) in [(&fill, "fill"), (&clip, "clip")] {
|
||||
for x in [12, 15, 20] {
|
||||
assert!(px(rgba, x, 149)[1] < 128, "{what}: ({x}, 149) on the line is {:?}", px(rgba, x, 149));
|
||||
assert!(px(rgba, x, 148)[1] > 128, "{what}: ({x}, 148) off the line is {:?}", px(rgba, x, 148));
|
||||
}
|
||||
for x in [100, 105, 108] {
|
||||
assert!(px(rgba, x, 148)[1] < 128, "{what}: ({x}, 148) on the line is {:?}", px(rgba, x, 148));
|
||||
assert!(px(rgba, x, 149)[1] > 128, "{what}: ({x}, 149) off the line is {:?}", px(rgba, x, 149));
|
||||
}
|
||||
// One pixel wide: its ink adds up to its length, about 100 pixels, not two rows' worth.
|
||||
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 disconnected_subpaths_on_one_line_leave_the_gap_between_them_unpainted() {
|
||||
// Two zero-height subpaths on user y = 50 (device row 150), x 10..30 and 60..90: the fill
|
||||
// and the clip mark the pixels each subpath touches, not the gap between them.
|
||||
let mut expected = Vec::new();
|
||||
for x in (10..30).chain(60..90) {
|
||||
expected.push((x, 150));
|
||||
}
|
||||
let fill = render("1 0 0 rg 10 50 m 30 50 l 60 50 m 90 50 l f");
|
||||
let clip = render(&format!("q 10 50 m 30 50 l 60 50 m 90 50 l W n {FLOOD} Q"));
|
||||
for (rgba, what) in [(&fill, "fill"), (&clip, "clip")] {
|
||||
assert_eq!(red_pixels(rgba), expected, "{what}: the red pixels");
|
||||
assert_eq!(tinted_pixels(rgba), 0, "{what}: partly covered pixels");
|
||||
}
|
||||
// Overlapping subpaths drawn in opposite directions still paint their union under either rule.
|
||||
let fill = render("1 0 0 rg 10 50 m 60 50 l 90 50 m 40 50 l f*");
|
||||
assert_only_red_run(&fill, (10, 90), (150, 151), "overlapping subpaths");
|
||||
let clip = render(&format!("q 10 50 m 60 50 l 90 50 m 40 50 l W* n {FLOOD} Q"));
|
||||
assert_only_red_run(&clip, (10, 90), (150, 151), "overlapping subpaths: clip");
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+135
-7
@@ -3,7 +3,7 @@ use crate::context::{Context, path_as_rect};
|
||||
use crate::device::Device;
|
||||
use crate::util::{BezPathExt, Float32Ext};
|
||||
use crate::{BlendMode, FillRule, Paint, PathDrawMode, StrokeProps};
|
||||
use kurbo::{BezPath, Cap, Join, PathEl, Point};
|
||||
use kurbo::{Affine, BezPath, Cap, Join, PathEl, Point, Rect, Vec2};
|
||||
use smallvec::smallvec;
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -93,11 +93,127 @@ 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(), 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 (user
|
||||
/// space) has exactly no width or exactly no height, but not both, under `ctm`: one area per
|
||||
/// subpath (see `degenerate_line_area`), kept as separate subpaths of one path, so a gap between
|
||||
/// two subpaths on the same line stays unpainted (ISO 32000-2 §10.7.4 marks only the pixels the
|
||||
/// shape touches). Every area winds the same way, so the non-zero rule paints their union. `None`
|
||||
/// for a path with area, a path that is a point, or one none of whose subpaths is a line: those
|
||||
/// paint as before.
|
||||
pub(crate) fn degenerate_fill_area(path: &BezPath, ctm: Affine) -> Option<BezPath> {
|
||||
let bbox = path.fast_bounding_box();
|
||||
if (bbox.width() == 0.0) == (bbox.height() == 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut area = BezPath::new();
|
||||
let mut add = |subpath: &BezPath| {
|
||||
if subpath.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut corners) = degenerate_line_area(subpath.fast_bounding_box(), ctm) else {
|
||||
return;
|
||||
};
|
||||
// Shoelace: wind every area the same way, so overlapping ones add up under non-zero.
|
||||
let twice_area: f64 = (0..4)
|
||||
.map(|i| {
|
||||
let (p, q) = (corners[i], corners[(i + 1) % 4]);
|
||||
p.x * q.y - q.x * p.y
|
||||
})
|
||||
.sum();
|
||||
if twice_area < 0.0 {
|
||||
corners.reverse();
|
||||
}
|
||||
area.move_to(corners[0]);
|
||||
for corner in &corners[1..] {
|
||||
area.line_to(*corner);
|
||||
}
|
||||
area.close_path();
|
||||
};
|
||||
let mut subpath = BezPath::new();
|
||||
for el in path.elements() {
|
||||
if matches!(el, PathEl::MoveTo(_)) {
|
||||
add(&subpath);
|
||||
subpath.truncate(0);
|
||||
}
|
||||
subpath.push(*el);
|
||||
}
|
||||
add(&subpath);
|
||||
(!area.elements().is_empty()).then_some(area)
|
||||
}
|
||||
|
||||
/// PdfCraft patch (65): the corners of the device-space area a fill marks for one subpath whose
|
||||
/// bounding box `bbox` (user space) has exactly no width or exactly 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. A box with area, however thin, is not one of these: scaled up it
|
||||
/// can cover many pixels.
|
||||
///
|
||||
/// When the line stays within one device row or column (counting a line less than 1/256 of a
|
||||
/// pixel across as lying exactly along the axis, at its middle), the area is the run of whole
|
||||
/// pixels it touches, by the half-open rule: from the floor of its low end to the ceiling of its
|
||||
/// high end, at least one pixel. Otherwise (a rotated or skewed `ctm` that carries it across
|
||||
/// rows and columns) it is a band one device pixel wide centred on the line, like the thinnest
|
||||
/// line of §8.4.3.2, so each row or column is marked only where the line runs through it. `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.
|
||||
fn degenerate_line_area(bbox: Rect, ctm: Affine) -> Option<[Point; 4]> {
|
||||
let flat_x = bbox.width() == 0.0;
|
||||
let flat_y = bbox.height() == 0.0;
|
||||
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);
|
||||
let (x0, x1) = touched_pixels(device.x0, device.x1);
|
||||
let (y0, y1) = touched_pixels(device.y0, device.y1);
|
||||
if x1 - x0 <= 1.0 || y1 - y0 <= 1.0 {
|
||||
return Some([Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)]);
|
||||
}
|
||||
let half = Vec2::new(-along.y, along.x) * (0.5 / length);
|
||||
Some([a + half, b + half, b - half, a - half])
|
||||
}
|
||||
|
||||
/// PdfCraft patch (65): the device pixel boundaries `(first, end)` of the pixels a span `lo..=hi`
|
||||
/// (`lo <= hi`, finite) touches along one axis by the half-open rule, at least one pixel. A span
|
||||
/// under 1/256 of a pixel is taken at its middle, so a line along a device axis that a CTM's
|
||||
/// rounding tilts slightly still marks one row or column.
|
||||
fn touched_pixels(lo: f64, hi: f64) -> (f64, f64) {
|
||||
let (lo, hi) = if ((hi - lo) as f32).is_nearly_zero() {
|
||||
let mid = lo + (hi - lo) / 2.0;
|
||||
(mid, mid)
|
||||
} else {
|
||||
(lo, hi)
|
||||
};
|
||||
let first = lo.floor();
|
||||
(first, hi.ceil().max(first + 1.0))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -151,10 +267,22 @@ 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();
|
||||
|
||||
match (
|
||||
(bbox.width() as f32).is_nearly_zero(),
|
||||
(bbox.height() as f32).is_nearly_zero(),
|
||||
) {
|
||||
// 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(path, 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;
|
||||
}
|
||||
|
||||
// PdfCraft patch (65): only a box with exactly no width or height (by now a point) is
|
||||
// stroked. A box with area, however thin, is filled as the shape it is: a "nearly zero"
|
||||
// side scaled up by the CTM can cover many device pixels.
|
||||
match (bbox.width() == 0.0, bbox.height() == 0.0) {
|
||||
(false, false) => {
|
||||
let draw_mode = PathDrawMode::Fill(fill_rule);
|
||||
if let Some(rect) = path_as_rect(path) {
|
||||
|
||||
Reference in New Issue
Block a user