diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 3071e94..b41c2de 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -121,6 +121,16 @@ fn decode_at(bytes: &[u8], side: u32) -> Option { 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>, want: f64, adj: &PictureAdjust) -> Option> { let addr = Arc::as_ptr(bytes) as usize; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 89b1342..5ea7e3d 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 99ce03c..a9b64d7 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -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 { + 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)); +}