Render: pattern fills blend neighbouring cells between whole-pixel sizes

Pattern tiles were sampled nearest-cell, exact when a pattern cell
(0.75 pt) is a whole number of output pixels (a 960-wide export of a
720 pt slide) but harsh otherwise: at 960 px for a 960 pt slide (0.75 px
per cell) PowerPoint shows greys where a pixel straddles two cells, and
DeckCraft showed only the two colours, in different places. The tile is
now sampled bilinearly. Fitted against PowerPoint's export of all 54
presets at 0.75 px per cell: mean error 0.067 (bilinear) against 0.082
(nearest) and 0.095 (area average); still not exact (narVert reads
255/128/0 in PowerPoint, 213/128/42 here). Unchanged at whole-pixel sizes.

Wild synthetic set: patterned backgrounds 21.15 -> 11.31 and
18.00 -> 9.75, pattern galleries 22.50 -> 19.44 and 13.96 -> 11.64.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 06:27:13 -04:00
parent 3951bf92f1
commit 10a7a4f2a4
2 changed files with 26 additions and 1 deletions

View File

@ -109,7 +109,10 @@ pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64,
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low,
// Bilinear, as PowerPoint samples the tile: exact at a whole number of output pixels
// per cell; between, a pixel straddling two cells takes a blend of both (fitted to its
// export at 0.75 px per cell: mean error 0.067 against 0.082 for the nearest cell).
quality: peniko::ImageQuality::Medium,
alpha: 1.0,
},
});

View File

@ -535,3 +535,25 @@ fn a_groups_shadow_is_cast_by_its_children() {
assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150));
assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it");
}
#[test]
fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() {
use deckcraft_model::style::PatternFill;
// narVert (alternate cells) at 0.75 output pixels per cell: PowerPoint's export reads
// 255, 128, 0 repeating across; drawing the nearest cell gave only black and white. Bilinear
// sampling gives about 213, 128, 42 (closer on average over all presets, not exact).
let g = |v: u8| ColorRef::rgb(Rgba::rgb(v, v, v));
let sh = Shape {
id: ShapeId(25),
xfrm: Some(Xfrm::new(0.0, 0.0, 60.0, 60.0)),
fill: Some(Fill::Pattern(PatternFill { preset: "narVert".into(), fg: g(0), bg: g(255) })),
line: no_line(),
..Default::default()
};
let img = render(vec![sh]);
let row: Vec<u8> = (12..24).map(|x| img.pixel(x, 20)[0]).collect();
assert!(row.iter().any(|v| (100..=156).contains(v)), "a blended column: {row:?}");
assert!(row.iter().any(|v| *v < 60) && row.iter().any(|v| *v > 200), "{row:?}");
// The pattern repeats every 6 px (8 cells).
assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}");
}