mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 02:57:01 -06:00
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>
212 lines
8.7 KiB
Rust
212 lines
8.7 KiB
Rust
//! 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,
|
||
// 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,
|
||
},
|
||
});
|
||
// 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"));
|
||
}
|
||
}
|