mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
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
296 lines
15 KiB
Rust
296 lines
15 KiB
Rust
//! 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());
|
||
}
|
||
}
|