Render: tiled picture fills flip, align and size like PowerPoint's
- flip x/y/xy (every other tile mirrored) was ignored: tiles now repeat reflected across the flip axes. - algn was ignored: the first tile now sits on the corner, edge middle or centre of the box it names, before the tx/ty offset. - The tile size came from the decoded (possibly downsampled) bitmap; it is now the picture's own pixel size (96 dpi) times sx/sy, rounded to whole output pixels as PowerPoint draws it. - A scale of zero or less drew tiles at 100%; PowerPoint draws nothing. - Tiles are one repeating image paint instead of one draw per tile (no 200-tile cap, constant cost). Wild synthetic set: tiled background flipped/aligned 82.92 -> 1.80, tile variants 51.15 -> 8.82. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7fb5430cc4
commit
fa32300d67
|
|
@ -121,6 +121,16 @@ fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
|||
Some(img.to_rgba8())
|
||||
}
|
||||
|
||||
/// A raster picture's own size in pixels, read from its header (`None` for metafiles and
|
||||
/// anything unreadable).
|
||||
pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
if crate::metafile::is_metafile(bytes) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
(w > 0 && h > 0).then_some((w as f64, h as f64))
|
||||
}
|
||||
|
||||
/// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px.
|
||||
pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {
|
||||
let addr = Arc::as_ptr(bytes) as usize;
|
||||
|
|
|
|||
|
|
@ -191,24 +191,67 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
|||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() },
|
||||
};
|
||||
if let PictureMode::Tile { sx, sy, tx, ty, .. } = &pf.mode {
|
||||
let (sx, sy) = (if *sx > 0.0 { *sx } else { 1.0 }, if *sy > 0.0 { *sy } else { 1.0 });
|
||||
let tile_w = iw * 0.75 * sx;
|
||||
let tile_h = ih * 0.75 * sy;
|
||||
let nx = ((bounds.width() / tile_w.max(1.0)).ceil() as i64).clamp(0, 200);
|
||||
let ny = ((bounds.height() / tile_h.max(1.0)).ceil() as i64).clamp(0, 200);
|
||||
for j in -1..=ny {
|
||||
for i in -1..=nx {
|
||||
let r = Rect::new(bounds.x0 + tx + i as f64 * tile_w, bounds.y0 + ty + j as f64 * tile_h, 0.0, 0.0);
|
||||
let r = Rect::new(r.x0, r.y0, r.x0 + tile_w, r.y0 + tile_h);
|
||||
draw_image(ctx, &image, iw, ih, r, m, alpha);
|
||||
}
|
||||
if let PictureMode::Tile { sx, sy, tx, ty, flip, align } = &pf.mode {
|
||||
// The tile's size: the picture's own size (96 dpi) scaled. A scale of zero or less draws
|
||||
// nothing, as in PowerPoint.
|
||||
if !(*sx > 0.0 && *sy > 0.0 && sx.is_finite() && sy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih));
|
||||
// PowerPoint draws the tile at a whole number of output pixels.
|
||||
let k = px_scale.max(1e-9);
|
||||
let snap = |v: f64| (v * k).round().max(1.0) / k;
|
||||
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy));
|
||||
if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let o = tile_origin(bounds, align, (tile_w, tile_h), (*tx, *ty));
|
||||
// Every other tile is mirrored across the flip axes: a reflected repeat.
|
||||
let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat };
|
||||
let tiled = vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
sampler: peniko::ImageSampler {
|
||||
x_extend: ext(matches!(flip.as_str(), "x" | "xy")),
|
||||
y_extend: ext(matches!(flip.as_str(), "y" | "xy")),
|
||||
quality: peniko::ImageQuality::High,
|
||||
alpha: 1.0,
|
||||
},
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
if alpha < 0.999 {
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, None);
|
||||
}
|
||||
ctx.set_paint(tiled);
|
||||
ctx.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / iw, tile_h / ih));
|
||||
ctx.fill_rect(&bounds);
|
||||
ctx.reset_paint_transform();
|
||||
if alpha < 0.999 {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
draw_image(ctx, &image, iw, ih, dest, m, alpha);
|
||||
}
|
||||
|
||||
/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or
|
||||
/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point
|
||||
/// of the box, then moved by the offset (points).
|
||||
pub fn tile_origin(bounds: Rect, align: &str, tile: (f64, f64), offset: (f64, f64)) -> Point {
|
||||
let (ax, ay) = match align {
|
||||
"t" => (0.5, 0.0),
|
||||
"tr" => (1.0, 0.0),
|
||||
"l" => (0.0, 0.5),
|
||||
"ctr" => (0.5, 0.5),
|
||||
"r" => (1.0, 0.5),
|
||||
"bl" => (0.0, 1.0),
|
||||
"b" => (0.5, 1.0),
|
||||
"br" => (1.0, 1.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let (ox, oy) = (if offset.0.is_finite() { offset.0 } else { 0.0 }, if offset.1.is_finite() { offset.1 } else { 0.0 });
|
||||
Point::new(bounds.x0 + ax * (bounds.width() - tile.0) + ox, bounds.y0 + ay * (bounds.height() - tile.1) + oy)
|
||||
}
|
||||
|
||||
fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) {
|
||||
let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih);
|
||||
ctx.set_transform(t);
|
||||
|
|
|
|||
|
|
@ -387,3 +387,53 @@ fn a_3d_pictures_border_stays_centred_on_its_edge() {
|
|||
assert!(close(img.pixel(102, 150), RED, 30), "covers the picture's edge {:?}", img.pixel(102, 150));
|
||||
assert!(close(img.pixel(94, 150), WHITE, 10), "reaches half the width out {:?}", img.pixel(94, 150));
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn half_red_half_blue() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn tiled(sx: f64, flip: &str, align: &str) -> Image {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("t.png", "image/png", half_red_half_blue());
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() };
|
||||
let sh = Shape {
|
||||
id: ShapeId(21),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_pictures_flip_align_and_skip_non_positive_scales() {
|
||||
let blue = Rgba::rgb(0, 0, 255);
|
||||
// 8×4 px at 96 dpi scaled ten times: 60×30 pt tiles, red then blue, from the top-left.
|
||||
let img = tiled(10.0, "none", "tl");
|
||||
assert!(close(img.pixel(115, 115), RED, 30) && close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), RED, 30));
|
||||
// Flipped across x: every other tile mirrored, so the second starts blue.
|
||||
let img = tiled(10.0, "x", "tl");
|
||||
assert!(close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), blue, 30) && close(img.pixel(205, 115), RED, 30));
|
||||
// Aligned right: a tile ends at the box's right edge (340), so 310-340 is blue.
|
||||
let img = tiled(10.0, "none", "r");
|
||||
assert!(close(img.pixel(335, 130), blue, 30) && close(img.pixel(305, 130), RED, 30), "{:?}", img.pixel(305, 130));
|
||||
// A negative scale draws nothing, as in PowerPoint.
|
||||
let img = tiled(-10.0, "none", "tl");
|
||||
assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert_eq!(paint::tile_origin(b, "tl", (20.0, 10.0), (0.0, 0.0)), Point::new(0.0, 0.0));
|
||||
assert_eq!(paint::tile_origin(b, "ctr", (20.0, 10.0), (0.0, 0.0)), Point::new(40.0, 20.0));
|
||||
assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0));
|
||||
assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue