Files
pdfcraft/crates/render/tests/it/stroke_geometry.rs
T
mtlcl410andClaude Opus 5.5 ba16b9b5b8 M2: a segment after h begins a new subpath (hayro patch 61)
ISO 32000-2 §8.5.2.1, Table 58: h "terminates the current subpath.
Appending another segment to the current path shall begin a new subpath,
even if the new segment begins at the endpoint reached by the h
operation." kurbo carries a segment after ClosePath on from the subpath
start without a MoveTo, so resolve_degenerate_subpaths saw "h" then an "l"
back to that point as part of the closed subpath, and the round-cap dot
of that degenerate subpath was lost (main drew it, through the nudge this
PR removed). l, c, v and y after h now start with a MoveTo to the point h
reached.

Review on #4410 (CodeRabbit, vendor/hayro-interpret/src/interpret/mod.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
2026-11-02 08:00:22 +08:00

296 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Stroke geometry the renderer gets from the graphics state: degenerate subpaths and their line
//! caps (ISO 32000-2 §8.5.3.2), dash arrays that are all zero or otherwise invalid (§8.4.3.6) and
//! line widths below zero (§8.4.3.2). Vendored hayro-interpret patch 61.
use std::sync::Arc;
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::hayro_syntax::Pdf;
use hayro::hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, StrokeProps,
TransformExt, interpret_page,
};
use kurbo::{Affine, BezPath, Rect};
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
const WHITE: [u8; 4] = [255, 255, 255, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// Renders `content` on a 40 × 40 pt page at 1 px per pt, with `gs1` as the ExtGState `/GS1`.
fn render(content: &str, gs1: &str) -> RenderedPage {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (40, 40));
page
}
/// The pixel at device (x, y), y down.
fn px(page: &RenderedPage, x: usize, y: usize) -> [u8; 4] {
let i = (y * 40 + x) * 4;
let mut out = [0; 4];
out.copy_from_slice(&page.rgba[i..i + 4]);
out
}
/// How many pixels are not white.
fn inked(page: &RenderedPage) -> usize {
page.rgba.as_chunks::<4>().0.iter().filter(|p| **p != WHITE).count()
}
/// §8.5.3.2: a degenerate subpath (a single-point closed path, or points that all coincide) is
/// painted as a filled circle of the line width when the caps are round, and not at all with butt
/// or projecting square caps. User-space point (20, 20) is device pixel (20, 20); a 10 pt round
/// dot covers (20 ± 4, 20) and (20, 20 ± 4) but not the square's corner (24, 24).
#[test]
fn degenerate_subpaths_draw_a_round_dot_or_nothing() {
let round = [
("m l to the same point", "0 0 1 RG 10 w 1 J 20 20 m 20 20 l S"),
("m h, a single-point closed path", "0 0 1 RG 10 w 1 J 20 20 m h S"),
("m h by s", "0 0 1 RG 10 w 1 J 20 20 m s"),
("m c with every point the same", "0 0 1 RG 10 w 1 J 20 20 m 20 20 20 20 20 20 c S"),
("m l l h at one point", "0 0 1 RG 10 w 1 J 20 20 m 20 20 l 20 20 l h S"),
("a 0 × 0 re", "0 0 1 RG 10 w 1 J 20 20 0 0 re S"),
];
for (what, content) in round {
let page = render(content, "<< >>");
for (x, y) in [(20, 20), (16, 20), (23, 20), (20, 16), (20, 23)] {
assert_eq!(px(&page, x, y), BLUE, "{what}, round caps: ({x}, {y}) is inside the dot");
}
assert_eq!(px(&page, 24, 24), WHITE, "{what}, round caps: the dot is round, not square");
assert_eq!(px(&page, 27, 20), WHITE, "{what}, round caps: the dot is the line width across");
}
for cap in ["0 J", "2 J"] {
for (what, content) in round {
let content = content.replace("1 J", cap);
assert_eq!(inked(&render(&content, "<< >>")), 0, "{what} with {cap}: nothing is painted");
}
}
// A single-point open subpath (a trailing m) paints nothing whatever the cap.
assert_eq!(inked(&render("0 0 1 RG 10 w 1 J 20 20 m S", "<< >>")), 0, "a lone m");
}
/// Only the degenerate subpaths of a path change: the line drawn with them keeps its caps, and a
/// line whose ends differ by a hair is not degenerate.
#[test]
fn degenerate_subpaths_leave_the_rest_of_the_path_alone() {
// Projecting square caps: the line from x 10 to 30 at y 10 reaches x 7 and x 33; the
// degenerate subpath at (20, 30) is dropped.
let page = render("0 0 1 RG 6 w 2 J 10 30 m 30 30 l 20 10 m h S", "<< >>");
for x in [8, 20, 32] {
assert_eq!(px(&page, x, 10), BLUE, "({x}, 10) on the line with its square caps");
}
assert_eq!(px(&page, 20, 30), WHITE, "the degenerate subpath is dropped");
// Round caps: the degenerate subpath becomes a dot beside the line.
let page = render("0 0 1 RG 6 w 1 J 10 30 m 30 30 l 20 10 m h S", "<< >>");
assert_eq!(px(&page, 20, 10), BLUE, "the line");
assert_eq!(px(&page, 20, 30), BLUE, "the dot");
// A trailing open single-point subpath (`m` with nothing after it) paints nothing, and leaves
// the line before it as it is, with either cap.
let page = render("0 0 1 RG 6 w 2 J 10 30 m 30 30 l 20 10 m S", "<< >>");
for x in [8, 20, 32] {
assert_eq!(px(&page, x, 10), BLUE, "({x}, 10) on the line before a trailing m");
}
assert_eq!(px(&page, 20, 30), WHITE, "a trailing m with square caps paints nothing");
let page = render("0 0 1 RG 6 w 1 J 10 30 m 30 30 l 20 10 m S", "<< >>");
assert_eq!(px(&page, 20, 10), BLUE, "the line before a trailing m");
assert_eq!(px(&page, 20, 30), WHITE, "a trailing m with round caps paints no dot");
// `b` (close, fill and stroke) of a single point strokes the dot too.
let page = render("0 0 1 RG 1 0 0 rg 6 w 1 J 20 20 m b", "<< >>");
assert_eq!(px(&page, 20, 20), BLUE, "b of one point strokes a round dot");
// A curve back to its start through other control points is a loop, not a point.
let page = render("0 0 1 RG 2 w 2 J 20 10 m 35 35 5 35 20 10 c S", "<< >>");
assert!(inked(&page) > 20, "a curve that returns to its start is stroked");
// A hairline dot is still drawn, about a device pixel across.
let page = render("0 0 1 RG 0 w 1 J 20 20 m h S", "<< >>");
assert!((1..=9).contains(&inked(&page)), "a 0 w round dot: {} pixels", inked(&page));
// Not degenerate: the ends differ, so the square caps are drawn along the hairline segment.
let page = render("0 0 1 RG 10 w 2 J 20 20 m 20.01 20 l S", "<< >>");
assert_eq!(px(&page, 20, 20), BLUE, "a segment a hundredth of a point long is stroked with its caps");
}
/// `h` "terminates the current subpath. Appending another segment to the current path shall begin
/// a new subpath, even if the new segment begins at the endpoint reached by the h operation"
/// (ISO 32000-2 §8.5.2.1, Table 58). So a segment after `h` back to the point `h` reached is a
/// degenerate subpath of its own: a round dot with round caps, nothing with the others. The closed
/// line from x 10 to x 30 turns back on itself at x 10, where the miter join falls back to a bevel
/// and paints nothing left of x 10; a 6 pt dot at (10, 20) reaches x 7.
#[test]
fn a_segment_after_h_begins_a_new_subpath() {
let page = render("0 0 1 RG 6 w 1 J 10 20 m 30 20 l h S", "<< >>");
assert_eq!(px(&page, 20, 20), BLUE, "the closed line");
assert_eq!(px(&page, 8, 20), WHITE, "a closed line has no cap at its start");
for (what, after) in [("l", "10 20 l"), ("c", "10 20 10 20 10 20 c"), ("v", "10 20 10 20 v"), ("y", "10 20 10 20 y")] {
let page = render(&format!("0 0 1 RG 6 w 1 J 10 20 m 30 20 l h {after} S"), "<< >>");
assert_eq!(px(&page, 20, 20), BLUE, "{what} after h: the closed line");
assert_eq!(px(&page, 8, 20), BLUE, "{what} after h, round caps: a dot at the point h reached");
for cap in ["0 J", "2 J"] {
let page = render(&format!("0 0 1 RG 6 w {cap} 10 20 m 30 20 l h {after} S"), "<< >>");
assert_eq!(px(&page, 20, 20), BLUE, "{what} after h with {cap}: the closed line");
assert_eq!(px(&page, 8, 20), WHITE, "{what} after h with {cap}: no dot");
}
}
// A segment after h that goes somewhere is an open subpath with caps of its own: with square
// caps the line from (10, 20) down to (10, 35) reaches y 38 (device row 2).
let page = render("0 0 1 RG 6 w 2 J 10 20 m 30 20 l h 10 35 l S", "<< >>");
assert_eq!(px(&page, 10, 3), BLUE, "the new subpath's projecting cap");
}
/// §8.4.3.6 asks for dash numbers that are nonnegative and not all zero, but not how to recover
/// from others. They draw a solid line, as an empty dash array does; before, `[0 0]` was cut into
/// hundredth-of-a-point dashes and came out a faint, half-covered line.
#[test]
fn all_zero_dash_arrays_draw_a_solid_line() {
for (what, content, gs1) in [
("[0 0]", "0 0 1 RG 4 w [0 0] 0 d 10 20 m 30 20 l S", "<< >>"),
("[0]", "0 0 1 RG 4 w [0] 0 d 10 20 m 30 20 l S", "<< >>"),
("[0 0 0] with a phase", "0 0 1 RG 4 w [0 0 0] 3 d 10 20 m 30 20 l S", "<< >>"),
("[0 0] from an ExtGState", "0 0 1 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0 0] 0] >>"),
("[0 -1], negative", "0 0 1 RG 4 w [0 -1] 0 d 10 20 m 30 20 l S", "<< >>"),
("[3 -1] from an ExtGState", "0 0 1 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[3 -1] 0] >>"),
] {
let page = render(content, gs1);
for x in 11..30 {
assert_eq!(px(&page, x, 20), BLUE, "{what}: ({x}, 20) is solid and opaque");
}
}
}
/// Patterns that are valid keep their dashes: `[0 12]` with round caps is a row of dots, `[4 4]`
/// leaves gaps, an odd `[4]` repeats as `[4 4]`.
#[test]
fn valid_dash_patterns_still_dash() {
let page = render("0 0 1 RG 4 w 1 J [0 12] 0 d 4 20 m 36 20 l S", "<< >>");
for x in [4, 16, 28] {
assert_eq!(px(&page, x, 20), BLUE, "[0 12] round caps: a dot at x {x}");
}
for x in [10, 22, 34] {
assert_eq!(px(&page, x, 20), WHITE, "[0 12] round caps: a gap at x {x}");
}
for (what, content) in [("[4 4]", "0 0 1 RG 4 w [4 4] 0 d 0 20 m 40 20 l S"), ("[4]", "0 0 1 RG 4 w [4] 0 d 0 20 m 40 20 l S")] {
let page = render(content, "<< >>");
assert_eq!(px(&page, 2, 20), BLUE, "{what}: the first dash");
assert_eq!(px(&page, 6, 20), WHITE, "{what}: the first gap");
assert_eq!(px(&page, 10, 20), BLUE, "{what}: the second dash");
}
}
/// The stroke properties and paints every `draw_path` of `content` reaches a device with.
fn strokes(content: &str, gs1: &str) -> Vec<(StrokeProps, Paint<'static>)> {
#[derive(Default)]
struct Strokes(Vec<(StrokeProps, Paint<'static>)>);
impl<'a> Device<'a> for Strokes {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, paint: &Paint<'a>, mode: &PathDrawMode) {
if let (PathDrawMode::Stroke(props), Paint::Color(colour)) = (mode, paint) {
self.0.push((props.clone(), Paint::Color(colour.clone())));
}
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let parsed = Pdf::new(Arc::new(pdf.into_bytes())).expect("parses");
let page = &parsed.pages()[0];
let cache = InterpreterCache::new();
let mut ctx =
Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = Strokes::default();
interpret_page(page, &mut ctx, &mut device);
device.0
}
/// §8.4.3.2: a line width of 0 is the thinnest line the device can draw. A negative or
/// non-finite one is invalid and kept as 0, so no device sees a width below zero; the stroke
/// keeps the current stroke colour.
#[test]
fn negative_line_widths_reach_a_device_as_zero() {
for (what, content, gs1, width) in [
("-6 w", "0 0 1 RG -6 w 10 20 m 30 20 l S", "<< >>", 0.0),
("/LW -6", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW -6 >>", 0.0),
("/LW 1e40, beyond f32", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW 10000000000000000000000000000000000000000 >>", 0.0),
("2.5 w", "0 0 1 RG 2.5 w 10 20 m 30 20 l S", "<< >>", 2.5),
("0 w", "0 0 1 RG 0 w 10 20 m 30 20 l S", "<< >>", 0.0),
] {
let seen = strokes(content, gs1);
assert_eq!(seen.len(), 1, "{what}: one stroke");
let (props, paint) = &seen[0];
assert_eq!(props.line_width, width, "{what}: line width");
let Paint::Color(colour) = paint else { unreachable!() };
assert_eq!(colour.to_rgba().to_rgba8(), [0, 0, 255, 255], "{what}: the stroke colour");
}
}
/// The dash array a device is given: `[]` (solid) for one that is all zero or has a negative or
/// non-finite entry, from `d` or from `/D`; a valid one keeps its entries, a zero entry turned
/// into a hundredth of a point, as before.
#[test]
fn invalid_dash_arrays_reach_a_device_as_solid() {
let line = "10 20 m 30 20 l S";
for (what, setup, gs1, dashes) in [
("[0 0]", "[0 0] 0 d", "<< >>", &[][..]),
("[0]", "[0] 0 d", "<< >>", &[]),
("[0 0 0] with a phase", "[0 0 0] 4 d", "<< >>", &[]),
("/D [[0 0] 0]", "/GS1 gs", "<< /D [[0 0] 0] >>", &[]),
("[3 -1]", "[3 -1] 0 d", "<< >>", &[]),
("/D [[0 -1] 0]", "/GS1 gs", "<< /D [[0 -1] 0] >>", &[]),
("[3 1e40], beyond f32", "[3 10000000000000000000000000000000000000000] 0 d", "<< >>", &[]),
("[]", "[] 0 d", "<< >>", &[]),
("[0 12]", "[0 12] 0 d", "<< >>", &[0.01, 12.0]),
("/D [[0 12] 0]", "/GS1 gs", "<< /D [[0 12] 0] >>", &[0.01, 12.0]),
("[20 10]", "[20 10] 5 d", "<< >>", &[20.0, 10.0]),
("[10]", "[10] 0 d", "<< >>", &[10.0]),
] {
let seen = strokes(&format!("{setup} {line}"), gs1);
assert_eq!(seen.len(), 1, "{what}: one stroke");
assert_eq!(seen[0].0.dash_array.as_slice(), dashes, "{what}");
}
}
/// The renderer already drew a negative width as its thinnest line (it widens anything under a
/// device pixel); that stays so, in the stroke colour.
#[test]
fn negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour() {
for (what, content, gs1, reference) in [
("-6 w", "0 0 1 RG -6 w 10 20 m 30 20 l S", "<< >>", "0 0 1 RG 0 w 10 20 m 30 20 l S"),
("/LW -6", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW -6 >>", "0 0 1 RG 0 w 10 20 m 30 20 l S"),
("-6 w with round caps", "0 0 1 RG 1 J -6 w 10 20 m 30 20 l S", "<< >>", "0 0 1 RG 1 J 0 w 10 20 m 30 20 l S"),
] {
let hairline = render(reference, "<< >>");
let page = render(content, gs1);
assert!(page.rgba[..] == hairline.rgba[..], "{what}: drawn as 0 w");
let ink: Vec<[u8; 4]> = page.rgba.as_chunks::<4>().0.iter().filter(|p| **p != WHITE).copied().collect();
assert!(!ink.is_empty(), "{what}: the line is drawn");
assert!(ink.iter().all(|p| p[2] == 255 && p[0] == p[1]), "{what}: every inked pixel is a tint of the blue stroke colour: {ink:?}");
assert!(ink.len() <= 2 * 22, "{what}: about one pixel thick, {} pixels inked", ink.len());
}
}