M2: a tiling pattern drawn once shows its cell at full scale (#3349) (#3356)

* M2: a tiling pattern drawn once shows its cell at full scale (#3349)

PowerPoint writes a picture fill as a tiling pattern with /XStep 2147480100.
Fitting that step into the 3000 px cell pixmap shrank the picture below a
pixel. When what is painted can only show the first cell, the cell's /BBox is
drawn at full scale with a transparent border and padded instead of tiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a tiling cell whose scale isn't finite keeps the old path (#3349)

Review follow-up on #3356: a /BBox 1e-40 wide passes the zero-area check and
gives an infinite cell scale, which would send non-finite geometry to the cell
renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a stroke's miter counts in the one-cell check (#3349)

Review follow-up on #3356: shows_one_cell took the line width as a stroke's
reach; a miter join reaches half the width times the miter limit. The reach
formula patch (9) uses is now one function, stroke_reach, used by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a skewed stroke's reach uses the transform's largest stretch (#3349)

Review on #3356: under a skew, a direction between the axes stretches
further than either axis, so the larger axis stretch could place a miter
tip short of the next cell when it reached it, and the one-cell path left
that cell unpainted. The reach now uses the spectral norm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-19 02:50:09 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4d42b48d20
commit b955c2eefb
4 changed files with 238 additions and 11 deletions
+135
View File
@@ -3462,6 +3462,141 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell
/// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels.
fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec<u8> {
let cell = "0 0 1 rg 0 0 10 10 re f";
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 {w} {h}] /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 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.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);
page.rgba.to_vec()
}
/// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with
/// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into
/// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn
/// at full scale, once, filled or stroked: where it is it shows, and around it (left, right,
/// above, below) nothing repeats or smears.
#[test]
fn a_tiling_pattern_drawn_once_shows_its_cell() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]);
// The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30.
let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&once, 40, 15, 25), blue, "the cell");
for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] {
assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it");
}
// Stroked: a wide line across the cell shows it too.
let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern");
assert_eq!(at(&stroked, 40, 25, 25), white);
// An ordinary step still tiles.
let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0");
assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell");
}
/// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40
/// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN
/// geometry reaches the cell renderer, the page renders and what follows still draws.
#[test]
fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() {
assert!(hayro::usable_cell_scale(2.0, 1.0));
for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] {
assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}");
}
let thin = "0.0000000000000000000000000000000000000001";
let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
let cell = "0 0 1 rg 0 0 10 10 re f";
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 << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.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.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws");
assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing");
}
/// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area
/// can't reach another: a page wide (or tall) enough to show the next cell, or the one before,
/// still tiles them.
#[test]
fn a_large_step_still_tiles_where_the_next_cell_shows() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let blue = vec![0, 0, 255];
// Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit,
// by 3000/3100; a pixel 4 points into a cell is blue either way.
let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f";
let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0");
assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right");
let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0");
assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left");
let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f";
let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0");
assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above");
let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100");
assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below");
// A stroke counts with its width: this line ends at x 3080, short of the next cell at
// 3100, but its square cap reaches 3110.
let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0");
assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap");
// A miter reaches further than the line width (review on #3356): this join's vertex is
// at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long
// under the default limit of 10, reaches 3130.
let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0");
// The tip narrows to a point, so this pixel is partly covered: blue, not white.
let tip = at(&mitered, 3200, 3104, 10);
assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}");
// Under a skew, a direction between the axes stretches more than either axis (review on
// #3356). This join's vertex is at device x 2980 and its miter tip, along that direction,
// at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell;
// the most any direction stretches (1.461) puts it at 3126.
let skewed = tiling_page(
3200,
20,
"/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S",
"3100",
"1 0 0 1 0 0",
);
// The tip narrows to a point, so its pixels are partly covered.
let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y));
assert!(
under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240),
"the next cell, under the skewed miter's tip: {:?}",
under_tip.collect::<Vec<_>>()
);
}
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode};
mod renderer;
/// PdfCraft patch: exported for its regression test.
pub use renderer::{image_resampling_size, tiling_cell_scale};
pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale};
/// PdfCraft patch: exported for its regression test.
#[doc(hidden)]
pub use renderer::set_skip_offscreen_paths;
+100 -8
View File
@@ -526,14 +526,7 @@ impl Renderer {
},
};
// A miter limit that isn't finite leaves miters unbounded: never skipped.
let miter = stroke.miter_limit.abs();
let reach = stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
};
let reach = stroke_reach(&stroke);
self.ctx.set_stroke(stroke);
reach
}
@@ -1142,6 +1135,65 @@ impl Renderer {
};
xs = xs.max(min_x_scale).min(max_x_scale);
ys = ys.max(min_y_scale).min(max_y_scale);
// PdfCraft patch (20): a step too large for a cell pixmap, where what is
// painted can only show the first cell (PowerPoint writes `/XStep
// 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is
// drawn at full scale with a transparent border and padded, not tiled.
// Shrinking the cell to fit the step left a picture under one pixel.
// How far a stroke's paint reaches past the path, miters included, in
// device pixels: the most the transform stretches any direction (a skew
// stretches some further than either axis), times the reach.
let reach = if is_stroke {
path_transform.spectral_norm() * stroke_reach(self.ctx.stroke())
} else {
0.0
};
if usable_cell_scale(xs, ys)
&& (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE)
&& shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step)
{
const BORDER: f64 = 1.0;
let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2);
let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2);
let mut renderer = Self {
ctx: RenderContext::new_with(
pix_width,
pix_height,
derive_settings(self.ctx.render_settings()),
),
color_replacement: None,
in_annotations: false,
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
in_type3_glyph: false,
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
};
let initial_transform = Affine::translate((BORDER, BORDER))
* Affine::scale_non_uniform(xs as f64, ys as f64)
* Affine::translate((-bbox.x0, -bbox.y0));
t.interpret(&mut renderer, initial_transform, is_stroke);
let mut pix = Pixmap::new(pix_width, pix_height);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse();
let image = Image {
image: ImageSource::Pixmap(Arc::new(pix)),
sampler: ImageSampler {
x_extend: peniko::Extend::Pad,
y_extend: peniko::Extend::Pad,
quality: ImageQuality::Medium,
alpha: 1.0,
},
};
self.ctx.set_paint_transform(paint_transform);
self.ctx.set_paint(PaintType::Image(image));
return clip_path;
}
// PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the
// u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells).
xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE);
@@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
peniko::BlendMode::new(mix, Compose::SrcOver)
}
/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width
/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that
/// isn't finite leaves miters unbounded, so the reach is too.
fn stroke_reach(stroke: &kurbo::Stroke) -> f64 {
let miter = stroke.miter_limit.abs();
stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
}
}
/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A
/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale,
/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path.
pub fn usable_cell_scale(xs: f32, ys: f32) -> bool {
xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0
}
/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach`
/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can
/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more
/// away. False when the matrix can't be inverted or the bounds aren't finite.
pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool {
if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() {
return false;
}
let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach);
let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box();
let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs()));
let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite());
// Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other.
finite
&& bounds.x1 < bbox.x0 + sx
&& bounds.x0 > bbox.x1 - sx
&& bounds.y1 < bbox.y0 + sy
&& bounds.y0 > bbox.y1 - sy
}
/// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| ×
/// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform
/// is derived from the scale); an over-large step only renders at lower resolution.