mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-05 01:49:35 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d45a64db2
commit
62ada3ab9a
4 files changed
+146
-8
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user