Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 351b2453b4 Render: text in a scaled group keeps its point size, as in PowerPoint
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:27:39 -04:00
Brandon ThomasandClaude Opus 5.5 bb6f0dc88a Render: pictures cast their shadow from their pixels, as in PowerPoint (#338)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:46:32 -04:00
NetterSilver c4f355773d Docs: update the progress docs for this change 2026-10-12 22:44:18 -04:00
NetterSilver d4bc8812a3 Credit Silvertree2010 in the About window 2026-10-12 22:44:18 -04:00
NetterSilver dc8a884389 Render: a group's child-coordinate scale no longer scales line widths (#233)
Outlines, dashes, arrowheads and ink strokes were drawn under the full
transform, including the mapping of the group's child coordinate space onto its
box. A group whose children use small legacy child coordinates (scale ~1600)
therefore stroked 0.75 pt lines about 1200 pt wide and covered the slide. Inside
a group the outline is now carried through that mapping and stroked under the
slide-level view, so the line width, dash lengths and arrowhead sizes stay in
points, also for groups scaled unevenly.
2026-10-12 22:44:18 -04:00
2 changed files with 130 additions and 3 deletions
+53 -3
View File
@@ -681,6 +681,24 @@ impl Renderer {
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
}
/// 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.
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
let (_, rel) = self.line_space(m, depth);
let c = rel.as_coeffs();
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
let ok = |k: f64| k.is_finite() && k > 1e-9;
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
return (w, h, m);
}
let (tw, th) = (w * sx, h * sy);
if !tw.is_finite() || !th.is_finite() {
return (w, h, m);
}
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return;
@@ -785,7 +803,12 @@ impl Renderer {
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
// Effects below the shape.
if let Some(e) = &effects {
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
let content = match (&s.kind, &fill) {
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
_ => None,
};
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
}
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
if let Some(r) = soft {
@@ -916,6 +939,16 @@ impl Renderer {
if let Some(body) = &s.text
&& !skip_text
{
// Text in a group is set at the shape's size on the slide, in points: a group's
// child-coordinate scale sizes and places the box, never the type (#233's twin).
let (tw, th, m) = self.text_space(m, depth, w, h);
let text_geo;
let geo = if (tw, th) == (w, h) {
&geo
} else {
text_geo = shape_geometry(s, tw, th);
&text_geo
};
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
@@ -959,6 +992,7 @@ impl Renderer {
ph: Option<Rgba>,
geo: &Geometry,
fill: &Option<Fill>,
content: Option<&deckcraft_model::style::PictureFill>,
m: Affine,
w: f64,
h: f64,
@@ -986,8 +1020,24 @@ impl Renderer {
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
match content {
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
// transparent background casts the logo's shadow): the picture, clipped to the
// shape, recoloured to the shadow colour.
Some(pf) => {
ctx.push_clip_layer(&silhouette);
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
ctx.pop_layer();
ctx.pop_layer();
}
None => {
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
}
}
ctx.pop_layer();
}
if let Some(g) = &e.glow {
+77
View File
@@ -1506,3 +1506,80 @@ fn group_child_scale_does_not_scale_line_widths() {
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
}
}
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
// logo's shadow, not its frame's. Here the left half of the image is transparent.
let png = {
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let shadow = deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 0.0,
dist: 20.0,
dir: 90.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
};
for as_fill in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("logo.png", "image/png", png.clone());
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
if as_fill {
sh.fill = Some(Fill::Picture(pf));
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
} else {
sh.kind = ShapeKind::Picture { fill: pf };
}
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Below the picture, 10 pt down: shadow under the opaque right half only.
let left = img.pixel(200, 310);
let right = img.pixel(400, 310);
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
// The transparent half shows the slide, not a shadow behind it.
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
}
}
#[test]
fn text_in_a_scaled_group_keeps_its_point_size() {
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
let draw = |scale: f64| {
let mut p = Presentation::default();
let mut body = TextBody::from_text("Hello");
body.paragraphs[0].runs[0].props.size = Some(24.0);
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
let g = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let ((_, y0, _, y1), _) = ink(&draw(1.0));
let plain = y1 - y0;
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
for scale in [1600.0, 4.0, 0.25] {
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
}
}