Files
pdfcraft/crates/render/tests/it/tiling_cell_placement.rs
T
mtlcl410andClaude Opus 5.5 a08ec799f5 M2: tiling pattern cells sit at their XStep/YStep multiples at every scale (ISO 32000-2 8.7.3.1) (#4421)
The cell pixmap was round(scale x step) pixels and repeated, but the cell
was drawn, and the paint transform mapped back, at the unrounded scale.
So the pixmap repeated a fraction of a pixel too far at every step (31 px
for a 30.56 px step at 110 dpi), and cells drifted further from their
place with every step from the pattern's origin.

Vendored hayro patch (25), `tiling_cell_pixels`: the cell is drawn at
round(scale x step) / step, so one step is exactly the pixmap's side, and
the paint transform uses that same scale. The side stays within 1 and the
existing 3000 px cap; a non-finite product keeps the scale and a bounded
side.

Tests: crates/render/tests/it/tiling_cell_placement.rs (fill, turned and
scaled matrix, stroke, all at 110 dpi far from the origin; the helper's
bounds).


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:30:02 -04:00

192 lines
9.5 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.
//! Tiling pattern cells sit at whole multiples of `/XStep` and `/YStep` in pattern space
//! (ISO 32000-2 8.7.3.1, Table 74), at any rendering scale, however far they are from the
//! pattern's origin.
//!
//! Each page is filled (or stroked) with a pattern whose 20 × 20 cell holds one 6 × 6 blue square
//! at (7, 7). At 110 dpi a step is 30.56 device pixels; every probe finds the blue square in a
//! disc around where the pattern matrix puts it and checks that its centroid is within one
//! device pixel of that place.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
/// The rendering scale for 110 dpi: a step of 20 points is 30.56 pixels, not a whole number.
const SCALE: f64 = 110.0 / 72.0;
/// The page is `SIDE` × `SIDE` points.
const SIDE: f64 = 400.0;
/// One page `SIDE` points square whose content `content` paints with `/P1`: the 20-point cell
/// with its blue square, `/XStep` and `/YStep` 20, under pattern matrix `matrix`.
fn page_pdf(content: &str, matrix: [f64; 6]) -> Vec<u8> {
let cell = "0 0 1 rg 7 7 6 6 re f";
let m = matrix.map(|v| format!("{v}")).join(" ");
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 {SIDE} {SIDE}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Matrix [{m}] /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.len()
)
.into_bytes()
}
struct Page {
rgba: Vec<u8>,
width: usize,
height: usize,
}
fn render(content: &str, matrix: [f64; 6]) -> Page {
let mut r = PageRenderer::new(Arc::new(page_pdf(content, matrix)), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: SCALE as f32, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
Page { rgba: page.rgba.to_vec(), width: page.width as usize, height: page.height as usize }
}
/// The device position of pattern-space point `(x, y)`: the pattern matrix to page space, then
/// page space to device pixels (y down).
fn device(matrix: [f64; 6], x: f64, y: f64) -> (f64, f64) {
let [a, b, c, d, e, f] = matrix;
let (px, py) = (a * x + c * y + e, b * x + d * y + f);
(px * SCALE, (SIDE - py) * SCALE)
}
/// The centroid of blue (weighted by how far each pixel is from white) in the disc of `radius`
/// pixels around `(cx, cy)`, and the total weight. `None` when the disc leaves the page.
fn blue_centroid(page: &Page, cx: f64, cy: f64, radius: f64) -> Option<((f64, f64), f64)> {
let (x0, x1) = ((cx - radius).floor(), (cx + radius).ceil());
let (y0, y1) = ((cy - radius).floor(), (cy + radius).ceil());
if x0 < 0.0 || y0 < 0.0 || x1 >= page.width as f64 || y1 >= page.height as f64 {
return None;
}
let (mut sum, mut sx, mut sy) = (0.0, 0.0, 0.0);
for j in (y0 as usize)..=(y1 as usize) {
for i in (x0 as usize)..=(x1 as usize) {
let (px, py) = (i as f64 + 0.5, j as f64 + 0.5);
if (px - cx).powi(2) + (py - cy).powi(2) > radius * radius {
continue;
}
let red = *page.rgba.get((j * page.width + i) * 4)?;
let w = f64::from(255 - red) / 255.0;
sum += w;
sx += w * px;
sy += w * py;
}
}
(sum > 0.0).then(|| ((sx / sum, sy / sum), sum))
}
/// Checks every cell `(k, m)` whose blue square's centre falls in the page rectangle `area`
/// (points: x0, y0, x1, y1): its square is found where the matrix puts it, within one pixel.
/// Returns how many cells were checked.
fn assert_cells_in_place(page: &Page, matrix: [f64; 6], area: [f64; 4], what: &str) -> usize {
let [a, b, c, d, _, _] = matrix;
// The square is 6 × 6 at (7, 7); its centre is (10, 10) in the cell. A disc of 6 cell
// units around it holds the whole square (half-diagonal 4.24) and no neighbour (7 away).
let units = (a * a + b * b).sqrt().min((c * c + d * d).sqrt());
let radius = 6.0 * units * SCALE;
let area_px = 36.0 * units * units * SCALE * SCALE;
let mut checked = 0;
for k in -60..60 {
for m in -60..60 {
let (cx_cell, cy_cell) = (20.0 * f64::from(k) + 10.0, 20.0 * f64::from(m) + 10.0);
let [ea, eb, ec, ed, ee, ef] = matrix;
let (px, py) = (ea * cx_cell + ec * cy_cell + ee, eb * cx_cell + ed * cy_cell + ef);
if px < area[0] || px > area[2] || py < area[1] || py > area[3] {
continue;
}
let (cx, cy) = device(matrix, cx_cell, cy_cell);
let Some(((gx, gy), weight)) = blue_centroid(page, cx, cy, radius) else { continue };
assert!(weight > 0.8 * area_px, "{what}: cell ({k}, {m}) at ({cx:.1}, {cy:.1}) only partly there: {weight:.1} of {area_px:.1}");
assert!(
(gx - cx).abs() <= 1.0 && (gy - cy).abs() <= 1.0,
"{what}: cell ({k}, {m}) should be centred at ({cx:.2}, {cy:.2}) px, found at ({gx:.2}, {gy:.2})"
);
checked += 1;
}
}
checked
}
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
/// A fill far from the pattern's origin: the cells 300 to 400 points away (15 to 19 steps) are
/// where 15 to 19 steps put them, not 0.44 pixel further for every step.
#[test]
fn far_cells_of_a_fill_sit_at_whole_steps() {
let page = render("/Pattern cs /P1 scn 0 0 400 400 re f", IDENTITY);
let near = assert_cells_in_place(&page, IDENTITY, [0.0, 0.0, 100.0, 100.0], "near the origin");
let far = assert_cells_in_place(&page, IDENTITY, [290.0, 290.0, 400.0, 400.0], "far from the origin");
assert!(near >= 16 && far >= 16, "{near} near, {far} far");
}
/// The same under a pattern matrix that scales by 1.49 and turns by 30 degrees: a step is
/// 45.5 pixels along each turned axis.
#[test]
fn far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix() {
let (scale, turn) = (1.49_f64, 30_f64.to_radians());
let matrix = [scale * turn.cos(), scale * turn.sin(), -scale * turn.sin(), scale * turn.cos(), 3.0, 5.0];
let page = render("/Pattern cs /P1 scn 0 0 400 400 re f", matrix);
let far = assert_cells_in_place(&page, matrix, [250.0, 250.0, 390.0, 390.0], "turned and scaled");
assert!(far >= 6, "{far} cells checked");
}
/// A stroke painted with the pattern: the cells under a wide border far from the origin are
/// in place on every side of it.
#[test]
fn far_cells_of_a_stroke_sit_at_whole_steps() {
let page = render("/Pattern CS /P1 SCN 40 w 200 200 180 180 re S", IDENTITY);
let mut checked = 0;
// The border is 40 points wide around the square from (200, 200) to (380, 380).
for band in [[180.0, 180.0, 400.0, 220.0], [180.0, 360.0, 400.0, 400.0], [180.0, 180.0, 220.0, 400.0], [360.0, 180.0, 400.0, 400.0]] {
checked += assert_cells_in_place(&page, IDENTITY, band, "stroke");
}
assert!(checked >= 20, "{checked} cells checked");
// Inside the border nothing is painted.
let (cx, cy) = device(IDENTITY, 290.0, 290.0);
let at = ((cy as usize) * page.width + cx as usize) * 4;
assert_eq!(page.rgba.get(at..at + 3), Some(&[255, 255, 255][..]), "inside the stroked square");
}
/// The cell pixmap's side and the scale its cell is drawn at: one step is exactly the side, the
/// side stays within 1 and the cap whatever the step, and nothing odd reaches the cast.
#[test]
fn a_cell_pixmap_spans_exactly_one_step() {
// 110 dpi, a step of 20: 30.56 pixels, drawn at 31 / 20.
assert_eq!(hayro::tiling_cell_pixels(110.0 / 72.0, 20.0, 3000.0), (31, 31.0 / 20.0));
assert_eq!(hayro::tiling_cell_pixels(110.0 / 72.0, -20.0, 3000.0), (31, 31.0 / 20.0), "a negative step");
for (scale, step) in [(1.0_f32, 7.3_f32), (2.37, 0.9), (0.013, 1234.5), (3.0, 999.9), (1.0, 2.0e9)] {
let (side, drawn) = hayro::tiling_cell_pixels(scale, step, 3000.0);
assert!((1..=3000).contains(&side), "{scale} {step}: {side}");
let span = drawn * f64::from(step);
assert!((span - f64::from(side)).abs() < 1e-9 * f64::from(side), "{scale} {step}: a step spans {span}, not {side}");
if f64::from(scale) * f64::from(step) <= 3000.0 {
assert!((span - f64::from(scale) * f64::from(step)).abs() <= 0.5 + 1e-9, "{scale} {step}: drawn {drawn}");
}
}
// A step under half a pixel still gets one pixel (a 0 × N pixmap can't be sampled).
assert_eq!(hayro::tiling_cell_pixels(0.03, 10.0, 3000.0), (1, 0.1));
// Steps and scales that aren't finite and positive: the side is bounded, the scale kept.
assert_eq!(hayro::tiling_cell_pixels(2.0, f32::INFINITY, 3000.0), (3000, 2.0));
assert_eq!(hayro::tiling_cell_pixels(2.0, 0.0, 3000.0), (1, 2.0));
let (side, drawn) = hayro::tiling_cell_pixels(f32::NAN, 20.0, 3000.0);
assert!(side == 1 && drawn.is_nan());
// A cap past u16::MAX is lowered to it, and the scale follows the side the pixmap gets.
let (side, drawn) = hayro::tiling_cell_pixels(2.0, 1.0e6, 1.0e9);
assert_eq!(side, u16::MAX, "the cap is at most u16::MAX");
assert!((drawn * 1.0e6 - f64::from(u16::MAX)).abs() < 1e-6, "a step spans the side: {drawn}");
// A cap that is not a whole number, or not a number, is a whole number of pixels.
assert_eq!(hayro::tiling_cell_pixels(1.0, 100.0, 30.7), (30, 0.3));
assert_eq!(hayro::tiling_cell_pixels(1.0, 100.0, f32::NAN), (1, 0.01));
}