From 62ada3ab9a00f9fc5724ded9814745b69b3d80ec Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 16:29:41 -0300 Subject: [PATCH 1/3] fix(render): tiled picture fills keep their size in scaled groups and upright tiles A tiled picture fill (a:blipFill with a:tile) was sized in the shape's own units. Inside a group whose child space differs from its box (old decks write child spaces ~1800 times smaller) one tile covered hundreds of boxes, so the fill showed a single flat texel instead of the texture. PowerPoint's own PNG export of a synthetic probe deck shows the tile keeps its size, offset, anchor and mirroring in slide points whatever the group's scale, uniform or stretched unevenly, nested or flipped. The tile is now converted from points to shape units per axis (Renderer::per_point_xy, paint:: tiled_per_point), and snapped to whole device pixels per axis. Tiles with rotWithShape="0" are drawn in device pixels and were sized in pixels, ignoring the zoom (even outside groups); they are now sized to pixels first. Tests: a tile in groups of seven scales (uniform and uneven, wide and tall tile), and an upright tile in a turned shape at zoom 1 and 2. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/render/src/paint.rs | 31 ++++++++-- crates/render/src/renderer.rs | 21 +++++-- crates/render/src/tests_wild.rs | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecf27f318..2b472291e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -319,6 +320,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index ee572fd98..3c4b1db54 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -91,7 +91,10 @@ pub fn fill_path( let on_path = m * path.clone(); ctx.set_transform(Affine::IDENTITY); ctx.push_clip_layer(&on_path); - picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None); + // Drawn in device pixels, so a tile (given in shape units) is sized to pixels first. + let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3]))); + let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf }; + picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None); ctx.pop_layer(); return; } @@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b } 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)); + // Per axis: a group can stretch its child space unevenly, and a pixel is then a different + // number of shape units across and down. + let c = m.as_coeffs(); + let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9)); + let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k; + let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky)); if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) { return; } @@ -260,6 +266,23 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b draw_image(ctx, &image, iw, ih, dest, m, alpha); } +/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given +/// the shape units to the point along x and y. A group's child coordinates scale its shapes' +/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint +/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of +/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is +/// returned as it is. +pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { + let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + let (kx, ky) = per_point; + if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { + return std::borrow::Cow::Borrowed(fill); + } + let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; + std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) +} + /// 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). diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index c25f6bab6..916f508e6 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -346,6 +346,14 @@ impl Renderer { if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } } + /// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child + /// space unevenly, and a tiled fill keeps its size in points on the slide on both axes. + fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) { + let c = self.line_space(m, depth).1.as_coeffs(); + let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 }; + (inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3]))) + } + /// The size and transform a shape's text is set in: inside a group whose child coordinates /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. @@ -541,6 +549,7 @@ impl Renderer { geo: &Geometry, (w, h): (f64, f64), m: Affine, + per_point: (f64, f64), fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, ) { for (i, sp) in geo.paths.iter().enumerate() { @@ -566,7 +575,10 @@ impl Renderer { paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); } } - _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + _ => { + let fl = paint::tiled_per_point(fl, per_point); + paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres) + } } ctx.set_fill_rule(peniko::Fill::NonZero); } @@ -736,6 +748,7 @@ impl Renderer { let fill = &d.fill; let outline = &d.outline; let fade = |i: usize| d.fade(i); + let pt_xy = self.per_point_xy(m, d.depth); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -744,7 +757,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); @@ -761,7 +774,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; @@ -787,7 +800,7 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } } } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index ae78f64b1..96a546f0c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -454,6 +454,106 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() { assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); } +#[allow(clippy::unwrap_used)] +fn red_over_blue() -> Vec { + let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 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() +} + +#[test] +fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A group's child space scales its shapes' boxes, not the tile: the same tiles show whether + // the group's child space is 1600 times smaller (old decks write them so), 4 times larger, + // the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its + // own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one + // flat texel, and an uneven group stretched the tile. + // Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across. + // Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down. + let blue = Rgba::rgb(0, 0, 255); + let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)]; + for (kx, ky) in scales { + for tall in [false, true] { + let mut p = Presentation::default(); + let png = if tall { red_over_blue() } else { half_red_half_blue() }; + let media = p.add_media("t.png", "image/png", png).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let member = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Along the tile's long side: first half red, second blue, then red again. + let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) }; + let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1)); + assert!( + close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)), + "child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}" + ); + } + } +} + +#[test] +fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in + // pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's + // export of the same shapes shows 60x30 pt tiles at every zoom. + let draw = |scale: f64, group: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() }); + let shape = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }), + fill: Some(fill), + line: no_line(), + ..Default::default() + }; + let shape = if group == 1.0 { + Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape } + } else { + Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)), + kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }, + ..Default::default() + } + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape]; + render_slide(&p, 0, &RenderOpts { scale, ..Default::default() }) + }; + for group in [1.0, 0.25] { + let (one, two) = (draw(1.0, group), draw(2.0, group)); + let (mut same, mut total) = (0, 0); + for y in (120..220).step_by(7) { + for x in (120..320).step_by(9) { + let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y)); + if close(a, WHITE, 5) && close(b, WHITE, 5) { + continue; // outside the turned rectangle + } + total += 1; + same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40)); + } + } + assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2"); + } +} + #[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); From 9e59b5f2238f386a44c0324e3dbb8690d19c7bd3 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:31:10 -0300 Subject: [PATCH 2/3] fix(render): size a group's own tile and a picture shape's own tile in points too Review of #1693 found two more places that drew a tiled picture fill in the shape's own (child-space) units inside a scaled group. A group nested in a scaled group stored its own tiled fill unconverted, so the members that take it (grpFill) got oversized tiles. The owning group now converts the fill with per_point_xy when it stores it, so inheriting members, directly or through a chain of grpFill groups, get the converted tile. A picture shape (p:pic) whose blip fill is a tile passed its own fill straight to paint::picture. It now goes through paint::tiled_picture_per_point, which tiled_per_point also uses. Tests: a group's tile passed to its members and a picture shape's own tile, each in outer groups scaled 1, 0.25, 1600 and 2x0.5; both fail without the change. Docs: the Picture and texture fill checklist row is re-assessed (remains P) with its status line and revision history, and the ROADMAP progress log row mentions both cases. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/paint.rs | 14 ++++++-- crates/render/src/renderer.rs | 6 ++-- crates/render/src/tests_wild.rs | 62 +++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2b472291e..3a1889a8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 3c4b1db54..74d111236 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -274,13 +274,21 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b /// returned as it is. pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; - let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + match tiled_picture_per_point(pf, per_point) { + std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill), + std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)), + } +} + +/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]). +pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> { + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) }; let (kx, ky) = per_point; if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { - return std::borrow::Cow::Borrowed(fill); + return std::borrow::Cow::Borrowed(pf); } let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; - std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) + std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() }) } /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 916f508e6..699a938f2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -436,7 +436,8 @@ impl Renderer { // A group's own fill (a group filled with its parent's passes that on). let own = match &s.fill { Some(Fill::Group) | None => self.group_fill.clone(), - Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), + // A tile is given in points; this group's own space may be scaled by its parents. + Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))), }; let outer = std::mem::replace(&mut self.group_fill, own); // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the @@ -763,7 +764,8 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); ctx.push_clip_layer(outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); + let pf = paint::tiled_picture_per_point(pf, pt_xy); + paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t); ctx.pop_layer(); } // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 96a546f0c..c81e481c5 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -506,6 +506,68 @@ fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { } } +/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is +/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point. +fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image { + let (w, h) = (240.0 / kx, 60.0 / ky); + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + }; + let mut p = p; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // The group that owns the tiled fill sits inside another group whose child space is scaled: the + // members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| { + let member = + Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() }; + Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + } + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + +#[test] +fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A picture (`p:pic`) whose blip fill is a tile rather than a stretch. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + ..Default::default() + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode}; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228d..8af3745ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,9 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged. +> +> **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -240,4 +242,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P and summary scores are unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From e90e7f6c03afbf7a3694e88521aff6be5df53ba0 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:46:33 -0300 Subject: [PATCH 3/3] fix(render): size the tile of a picture's shadow and reflection in points too Review of #1693 found that cast_effects built the picture content that a shadow, a glow and a reflection are drawn from without converting a tiled fill to points, so inside a scaled group the reflection of a tiled picture showed child-space tiles while its face showed the right ones. The content now goes through paint::tiled_picture_per_point with per_point_xy, as the face does. Test: a tiled picture's reflection in groups scaled 0.25, 1600 and 2x0.5 shows the same colours as in an unscaled group; it fails without the change. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/renderer.rs | 8 ++++++-- crates/render/src/tests_wild.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a1889a8c..050c3958b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 699a938f2..e7cd5ff35 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -668,9 +668,13 @@ impl Renderer { let line = &d.line; // Effects below the shape. if let Some(e) = &effects { - // A picture (or picture fill) casts its shadow from its pixels, not its frame. + // A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile + // is sized in points on the slide, as the face's is, and so is its shadow and reflection. let content = match (&s.kind, &fill) { - (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => { + let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth)); + Some(paint::resolved(rctx, &pf, None).into_owned()) + } _ => None, }; // What casts the shadow: the fill as see-through as it is, and the outline. diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c81e481c5..987781f15 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -568,6 +568,36 @@ fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { } } +#[test] +fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode, Reflection}; + // The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt, + // whatever the group's child scale: just below the box it shows the same colours. + let reflected = |kx: f64, ky: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }), + ..Default::default() + }); + [img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)] + }; + let plain = reflected(1.0, 1.0); + assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}"); + for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let got = reflected(kx, ky); + for (g, e) in got.iter().zip(&plain) { + assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}"); + } + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode};