Files
deckcraft/crates/render/src/pattern.rs
T
Brandon ThomasandClaude Opus 5.5 81b6a9df39 Render: pattern fills draw PowerPoint's preset tiles (#285)
Pattern fills were approximated (dots, bars or a grid per family, on an
8-point cell), so pct30 looked like sparse dots and wave like a dot grid.
Every one of the 54 DrawingML presets now draws its 8x8 tile, each cell one
96-dpi pixel (0.75 pt), laid out from the slide's origin. The tiles were
read from PowerPoint's own export of each preset at two output pixels per
cell; the pattern probe deck now matches it to a mean pixel difference of
0.2 (from 62).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:56:30 -04:00

209 lines
8.4 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.
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
//! (0.75 pt) of the slide, as PowerPoint draws them.
//!
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Side of one pattern cell, in points.
pub const CELL: f64 = 0.75;
const TILES: &[(&str, [u8; 8])] = &[
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
];
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
pub fn tile(name: &str) -> [u8; 8] {
TILES
.iter()
.find(|(n, _)| *n == name)
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
.map(|(_, t)| *t)
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
}
/// Is cell (`x`, `y`) of the tile the foreground?
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
}
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
}
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
/// `alpha`.
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
let det = m.determinant();
if !det.is_finite() || det.abs() <= 1e-12 {
return;
}
let t = tile(name);
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low,
alpha: 1.0,
},
});
// Cells are laid out on the output from its origin (the slide's top-left corner), so
// neighbouring shapes' patterns line up, as in PowerPoint.
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_drawingml_preset_has_a_tile() {
// ECMA-376 ST_PresetPatternVal.
let all = [
"pct5",
"pct10",
"pct20",
"pct25",
"pct30",
"pct40",
"pct50",
"pct60",
"pct70",
"pct75",
"pct80",
"pct90",
"horz",
"vert",
"ltHorz",
"ltVert",
"dkHorz",
"dkVert",
"narHorz",
"narVert",
"dashHorz",
"dashVert",
"cross",
"dnDiag",
"upDiag",
"ltDnDiag",
"ltUpDiag",
"dkDnDiag",
"dkUpDiag",
"wdDnDiag",
"wdUpDiag",
"dashDnDiag",
"dashUpDiag",
"diagCross",
"smCheck",
"lgCheck",
"smGrid",
"lgGrid",
"dotGrid",
"smConfetti",
"lgConfetti",
"horzBrick",
"diagBrick",
"solidDmnd",
"openDmnd",
"dotDmnd",
"plaid",
"sphere",
"weave",
"divot",
"shingle",
"wave",
"trellis",
"zigZag",
];
assert_eq!(all.len(), 54);
for n in all {
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
}
assert_eq!(TILES.len(), 54);
}
#[test]
fn percent_presets_get_denser() {
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
// half, pct90 all but 2).
let share = |n: &str| {
let t = tile(n);
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
};
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
assert_eq!(tile("nonsense"), tile("pct50"));
}
}