M2: patterned stencil and tiling cell tests count offscreen draws instead of timing them; interleaved settings and uncoloured patterns keep their own cells

hayro patch (30) gains a test hook, `offscreen_draws`: per thread, the tiling cells drawn
and the pixels of every pixmap drawn offscreen, all made by `offscreen_pixmap`. The two
wall-clock bounds become counts: 5,000 patterned stencils draw one cell and less than a page
of pixels offscreen, and 3,000 fills of one setting draw one cell. New tests interleave the
settings of two coloured patterns with cells of one size and one of them at two scales, each
showing its own pattern in place and drawing one cell per setting, and paint an uncoloured
pattern in two colours and, at half opacity, both as a fill and as a stencil mask's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410
2026-11-07 07:01:42 +08:00
co-authored by Claude Opus 5.5
parent f5d3841823
commit 1d968fea8f
4 changed files with 177 additions and 35 deletions
+126 -28
View File
@@ -7669,42 +7669,43 @@ trailer << /Root 1 0 R >>
/// pdf.js issue 8078 paints 73,047 small stencil masks with one tiling pattern. Each was drawn
/// through a canvas-sized luminance mask (a full-page render and composite per stencil), which
/// took minutes for the page; a stencil now costs what its rectangle covers. Here 5,000
/// patterned stencils on a 1000 × 2000 page.
/// patterned stencils on a 1000 × 2000 page. What the page draws offscreen is counted (hayro
/// patch 30) rather than timed, so the check is the same on every machine: the one pattern cell,
/// and not a page-sized pixmap per stencil.
#[test]
fn many_patterned_stencil_masks_render_in_time() {
fn many_patterned_stencil_masks_draw_no_page_sized_masks() {
let mut content = String::from("/Pattern cs /P scn ");
for i in 0..5000u32 {
content.push_str(&format!("q 16 0 0 16 {} {} cm /Msk Do Q ", (i % 50) * 20, (i / 50) * 20));
}
let started = std::time::Instant::now();
let before = hayro::offscreen_draws();
let page = patterned_stencil_page((1000, 2000), &content);
let took = started.elapsed();
assert!(took < std::time::Duration::from_secs(10), "5,000 patterned stencils took {took:?}");
let after = hayro::offscreen_draws();
assert_eq!(after.tiling_cells - before.tiling_cells, 1, "one cell for the pattern's one setting");
let pixels = after.pixels - before.pixels;
assert!(pixels < 1000 * 2000, "5,000 patterned stencils drew {pixels} pixels offscreen");
// The last stencil sits at (980, 1980): its ring is red, its centre white.
let px = |x: u32, y: u32| page.rgba[(((2000 - y) * page.width + x) * 4) as usize..][..3].to_vec();
assert_eq!(px(981, 1981), [255, 0, 0]);
assert_eq!(px(988, 1988), [255, 255, 255]);
}
/// A coloured tiling pattern's cell is drawn once for all the fills of one setting (`scn`): here
/// 3,000 small squares filled with a pattern whose cell holds 5,000 paths. Drawn again for each
/// fill, the cells took seconds, and each was kept until the page was composited (pdf.js issue
/// 8078 ran out of memory that way, its 3.5 MB cell drawn for each of 73,047 stencils). The
/// squares all show the pattern in place. The same holds for a step so large that the fills show
/// one cell, which is drawn with a transparent border instead of repeated.
/// A coloured tiling pattern's cell is drawn once for all the fills of one setting (`scn`), as
/// counted by hayro patch 30: here 3,000 small squares filled with one pattern. Drawn again for
/// each fill, each cell was kept until the page was composited (pdf.js issue 8078 ran out of
/// memory that way, its 3.5 MB cell drawn for each of 73,047 stencils). The squares all show the
/// pattern in place. The same holds for a step so large that the fills show one cell, which is
/// drawn with a transparent border instead of repeated.
#[test]
fn fills_of_one_pattern_setting_share_its_cell() {
let mut content = String::from("/Pattern cs /P scn ");
for i in 0..3000u32 {
content.push_str(&format!("{} {} 4 4 re f ", (i % 60) * 5, (i / 60) * 5));
}
// The cell is blue with a red lower-left quarter, under 5,000 blue specks; at `/Matrix` scale
// 10 a cell is 100 units, so with a step of 10 red shows where both coordinates, modulo 100,
// are below 50. With a step of 100000 the one cell shows and nothing past it.
let mut cell = String::from("0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f 0 0 1 rg");
for i in 0..5000u32 {
cell.push_str(&format!(" {}.{} 9.5 0.01 0.01 re f", i % 10, i % 7));
}
// The cell is blue with a red lower-left quarter; at `/Matrix` scale 10 a cell is 100 units,
// so with a step of 10 red shows where both coordinates, modulo 100, are below 50. With a
// step of 100000 the one cell shows and nothing past it.
let cell = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f";
for (step, past_the_first_cell) in [("10", [0, 0, 255]), ("100000", [255, 255, 255])] {
let pdf = format!(
"%PDF-1.7
@@ -7722,16 +7723,10 @@ trailer << /Root 1 0 R >>
content.len(),
cell.len()
);
let started = std::time::Instant::now();
let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
scale: 1.0,
..Default::default()
});
let took = started.elapsed();
assert!(page.error.is_none(), "step {step}: {:?}", page.error);
assert!(took < std::time::Duration::from_secs(10), "step {step}: 3,000 fills of one pattern took {took:?}");
let before = hayro::offscreen_draws();
let page = render_first_page(pdf.into_bytes());
let cells = hayro::offscreen_draws().tiling_cells - before.tiling_cells;
assert_eq!(cells, 1, "step {step}: cells drawn for 3,000 fills of one setting");
// Pixel rows run top down: PDF (x, y) is pixel (x, 300 - y).
let px = |x: u32, y: u32| page.rgba[(((300 - y) * page.width + x) * 4) as usize..][..3].to_vec();
// The first square, at (0, 0), and the last, at (295, 245).
@@ -7740,6 +7735,109 @@ trailer << /Root 1 0 R >>
}
}
/// Fills interleaving the settings of two coloured patterns with cells of one size, and of one
/// of them at two cell scales, each show their own pattern in place: a cell kept for one setting
/// is not taken for another (hayro patch 30). `/P` is blue with a red lower-left quarter and `/Q`
/// green with a yellow one, both at `/Matrix` scale 10, so a cell is 100 units and its quarter
/// shows where both coordinates, modulo 100, are below 50. The form `/X`, drawn at scale 2, sets
/// `/P` in its own space, where a cell is 200 units, red where both page coordinates, modulo
/// 200, are below 100. Each of the six settings draws its cell once, for all its fills.
#[test]
fn fills_of_interleaved_pattern_settings_each_show_their_own_cell() {
let content = "/Pattern cs /P scn 60 60 4 4 re f 10 10 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \
/Q scn 110 10 4 4 re f 160 160 4 4 re f /P scn 210 210 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \
/Q scn 260 260 4 4 re f 110 110 4 4 re f";
// In page space, squares at (60, 260) and (260, 60), where `/P` at scale 1 is blue.
let form = "/Pattern cs /P scn 30 130 2 2 re f 130 30 2 2 re f";
let p = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f";
let q = "0 1 0 rg 0 0 10 10 re f 1 1 0 rg 0 0 5 5 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 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R /Q 6 0 R >> /XObject << /X 7 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 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream
{p}
endstream endobj
6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream
{q}
endstream endobj
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 150 150] /Resources << /Pattern << /P 5 0 R >> >> /Length {} >> stream
{form}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
p.len(),
q.len(),
form.len()
);
let before = hayro::offscreen_draws();
let page = render_first_page(pdf.into_bytes());
assert_eq!(hayro::offscreen_draws().tiling_cells - before.tiling_cells, 6, "one cell per setting");
// The middle of the 4 × 4 square at PDF (x, y); pixel rows run top down.
let px = |x: u32, y: u32| page.rgba[(((300 - 2 - y) * page.width + x + 2) * 4) as usize..][..3].to_vec();
let (red, blue, green, yellow) = ([255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 255, 0]);
for (x, y, colour, what) in [
(60, 60, blue, "/P"),
(10, 10, red, "/P"),
(60, 260, red, "/P at scale 2"),
(260, 60, red, "/P at scale 2"),
(110, 10, yellow, "/Q"),
(160, 160, green, "/Q"),
(210, 210, red, "/P set again"),
(260, 260, green, "/Q set again"),
(110, 110, yellow, "/Q set again"),
] {
assert_eq!(px(x, y), colour, "{what} at ({x}, {y})");
}
}
/// An uncoloured tiling pattern (`/PaintType 2`) takes the colour of each setting, so fills that
/// alternate red and green settings of it show both, each in place. Its cell also takes the
/// opacity the setting captured, which a patterned stencil mask's copy of the setting leaves out
/// (its transparency group applies it, hayro-interpret patch 46), so the cell drawn for one fill
/// of a setting is not the other's (hayro patch 30 keeps only coloured cells): a square and a
/// stencil painted with one half-opaque blue setting show the same half blue.
#[test]
fn an_uncoloured_pattern_shows_each_settings_colour() {
let content = "/C cs 1 0 0 /U scn 10 10 20 20 re f 0 1 0 /U scn 40 10 20 20 re f \
1 0 0 /U scn 70 10 20 20 re f 0 1 0 /U scn 100 10 20 20 re f \
/H gs 0 0 1 /U scn 10 50 20 20 re f q 20 0 0 20 40 50 cm /Msk Do Q";
let tile = "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 150 100] /Contents 4 0 R /Resources << /ColorSpace << /C [/Pattern /DeviceRGB] >> /Pattern << /U 6 0 R >> /XObject << /Msk 5 0 R >> /ExtGState << /H << /ca 0.5 >> >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream
00000000>
endstream endobj
6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream
{tile}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
tile.len()
);
let page = render_first_page(pdf.into_bytes());
// Pixel rows run top down: PDF (x, y) is pixel (x, 100 - y).
let px = |x: u32, y: u32| page.rgba[(((100 - y) * page.width + x) * 4) as usize..][..3].to_vec();
for (x, colour) in [(20, [255, 0, 0]), (50, [0, 255, 0]), (80, [255, 0, 0]), (110, [0, 255, 0])] {
assert_eq!(px(x, 20), colour, "the square at x {x}");
}
let (square, stencil) = (px(20, 60), px(50, 60));
assert!(square[2] == 255 && square[0].abs_diff(128) <= 1 && square[1] == square[0], "half blue: {square:?}");
assert!(square.iter().zip(&stencil).all(|(a, b)| a.abs_diff(*b) <= 2), "square {square:?}, stencil {stencil:?}");
}
/// #2971: in a knockout transparency group (`/K true`) each element composites with the
/// group's initial backdrop, not with the elements drawn before it in the group, over the
/// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue
+2 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -65,6 +65,9 @@ pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale,
/// PdfCraft patch: exported for its regression test.
#[doc(hidden)]
pub use renderer::set_skip_offscreen_paths;
/// PdfCraft patch (30): exported for its regression tests.
#[doc(hidden)]
pub use renderer::{OffscreenDraws, offscreen_draws};
/// A cache used by the renderer.
///
+46 -5
View File
@@ -637,6 +637,47 @@ pub fn set_skip_offscreen_paths(skip: bool) {
SKIP_OFFSCREEN_PATHS.with(|s| s.set(skip));
}
/// PdfCraft patch (30): what the renders on this thread have drawn offscreen, for tests that check
/// a page's cost by counting rather than by timing it (see [`offscreen_draws`]).
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OffscreenDraws {
/// Tiling pattern cells drawn; a cell taken from `tiling_cells` isn't drawn again.
pub tiling_cells: u64,
/// The pixels of every pixmap drawn offscreen: tiling cells, masks, groups and the like.
pub pixels: u64,
}
thread_local! {
/// PdfCraft patch (30): see [`OffscreenDraws`].
static OFFSCREEN_DRAWS: std::cell::Cell<OffscreenDraws> =
const { std::cell::Cell::new(OffscreenDraws { tiling_cells: 0, pixels: 0 }) };
}
/// PdfCraft patch (30): exported for its regression tests (see [`OffscreenDraws`]). The counts
/// only grow; a test takes the difference across a render.
#[doc(hidden)]
pub fn offscreen_draws() -> OffscreenDraws {
OFFSCREEN_DRAWS
.try_with(std::cell::Cell::get)
.unwrap_or_default()
}
/// PdfCraft patch (30): a `width` × `height` pixmap to draw offscreen, counted in
/// [`OffscreenDraws`]. Every pixmap the renderer renders into, besides the page, is made here.
fn offscreen_pixmap(width: u16, height: u16, tiling_cell: bool) -> Pixmap {
// Gone only while the thread exits, when there is nothing left to count for.
let _ = OFFSCREEN_DRAWS.try_with(|draws| {
let mut counts = draws.get();
counts.pixels = counts
.pixels
.saturating_add(u64::from(width) * u64::from(height));
counts.tiling_cells = counts.tiling_cells.saturating_add(u64::from(tiling_cell));
draws.set(counts);
});
Pixmap::new(width, height)
}
/// PdfCraft patch: whether a device-space box can paint a pixel of a `width` × `height`
/// canvas. A box whose bounds miss the canvas is skipped without changing a pixel.
/// Bounds that aren't finite are drawn as before.
@@ -1066,7 +1107,7 @@ impl Renderer {
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height());
let mut mask_pix = offscreen_pixmap(self.ctx.width(), self.ctx.height(), false);
let rgb_data = ImageData::Rgb(RgbData {
data: dummy_rgb,
width: alpha_data.width,
@@ -1614,7 +1655,7 @@ impl Renderer {
ink: InkState::default(),
};
t.interpret(&mut renderer, initial_transform, is_stroke);
let mut pix = Pixmap::new(size.0, size.1);
let mut pix = offscreen_pixmap(size.0, size.1, true);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
@@ -1695,7 +1736,7 @@ impl Renderer {
if renderer.ink.unsupported {
return None;
}
let mut pix = Pixmap::new(width, height);
let mut pix = offscreen_pixmap(width, height, false);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
@@ -2434,7 +2475,7 @@ impl<'a> Device<'a> for Renderer {
return false;
}
let mut result = Pixmap::new(width, height);
let mut result = offscreen_pixmap(width, height, false);
for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) {
let a = u32::from(coverage.a);
let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8;
@@ -2846,7 +2887,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
renderer.ctx.pop_layer();
}
let mut pix = Pixmap::new(width, height);
let mut pix = offscreen_pixmap(width, height, false);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);