From 8dafbcb8c28d348f9c3bbc3c404f5f5a2cfdc9d6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:10:59 -0400 Subject: [PATCH] Render: a picture's border lies outside the picture DeckCraft centred a picture's outline on its edge, covering half the border's width of the picture. PowerPoint draws picture borders wholly outside: an 18 pt white photo frame leaves the whole photo showing and reaches 18 pt out (measured on its export; a 2 pt border on a cropped picture covers 18-20 pt for a picture starting at 20). Pictures now stroke their border twice as wide, clipped to outside the outline, keeping dash lengths; the border's shadow is cast from there too. Compound borders stay centred. Wild synthetic set: picture frames 13.12 -> 8.54, cropped/flipped pictures with borders 5.79 -> 2.57, crop-to-shape 8.03 -> 5.25. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 43 ++++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eba31f8..ae2598e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -864,7 +864,9 @@ impl Renderer { _ => 1.0, }; let (base, rel) = self.line_space(m, depth); - Caster { line: &line, ph: line_ph, alpha: a, base, rel } + let lw = line.width.unwrap_or(0.75).max(0.0); + let outside = if is_pic { outside_line(&line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); } @@ -973,6 +975,22 @@ impl Renderer { } None => std::borrow::Cow::Borrowed(&line), }; + // A picture's border lies outside the picture, as PowerPoint draws it: stroked twice + // as wide, outside the outline only. + let outside = is_pic.then(|| outside_line(&line, lw)).flatten(); + let clipped = outside.is_some(); + let (line, lw) = match outside { + Some(o) => { + let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1); + clip.extend(outline.iter()); + ctx.set_transform(m); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.push_clip_layer(&clip); + ctx.set_fill_rule(peniko::Fill::NonZero); + (std::borrow::Cow::Owned(o), lw * 2.0) + } + None => (line, lw), + }; let first_stroked = geo.paths.iter().position(|p| p.stroke); for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { let mut path = if rel == Affine::IDENTITY { @@ -991,6 +1009,9 @@ impl Renderer { if ends { arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); } + if clipped { + ctx.pop_layer(); + } if see_through.is_some() { ctx.pop_layer(); } @@ -1153,7 +1174,7 @@ impl Renderer { } // … and the outline (a picture's border too). if let Some(o) = &outline { - let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.clone() }; + let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() }; let lw = o.line.width.unwrap_or(0.75).max(0.0); for sp in geo.paths.iter().filter(|sp| sp.stroke) { let mut path = sp.path.clone(); @@ -1197,10 +1218,26 @@ impl Renderer { } } +/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside +/// the outline is the border, with dashes kept their length. `None` for compound lines, whose +/// bands would not survive the doubling (they stay centred on the outline). +fn outside_line(line: &Line, lw: f64) -> Option { + if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) { + return None; + } + let dash = line.dash.as_ref().map(|d| { + let p = d.pattern(); + deckcraft_model::style::Dash::Custom { + pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(), + } + }); + Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() }) +} + /// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity, /// and the space it is stroked in (see `Renderer::line_space`). struct Caster<'a> { - line: &'a Line, + line: std::borrow::Cow<'a, Line>, ph: Option, alpha: f64, base: Affine, diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 0e1229c..a58bf3a 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -250,3 +250,40 @@ fn shadows_are_cast_by_what_is_drawn() { let img = render(vec![ln]); assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100)); } + +#[allow(clippy::unwrap_used)] +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +fn picture_deck(line: Option) -> Presentation { + let mut p = Presentation::default(); + let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])); + let sh = Shape { + id: ShapeId(15), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + line, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + p +} + +#[test] +fn a_pictures_border_lies_outside_the_picture() { + let p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0))); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let blue = Rgba::rgb(0, 0, 255); + // The whole picture shows, up to its edge; the 8 pt border is all outside it. + assert!(close(img.pixel(101, 150), blue, 30), "picture at its edge {:?}", img.pixel(101, 150)); + assert!(close(img.pixel(98, 150), RED, 30) && close(img.pixel(93, 150), RED, 30), "{:?}", img.pixel(93, 150)); + assert!(close(img.pixel(90, 150), WHITE, 10), "border no wider than the line {:?}", img.pixel(90, 150)); + // A dashed border keeps its dash lengths (a dash is 4 widths: 32 pt), drawn from the corner. + let mut line = Line::solid(ColorRef::rgb(RED), 8.0); + line.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() }); + let img = render_slide(&picture_deck(Some(line)), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert!(close(img.pixel(120, 96), RED, 30) && close(img.pixel(140, 96), WHITE, 10), "{:?}", img.pixel(140, 96)); +}