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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:10:59 -04:00
parent ac8c40c2eb
commit 8dafbcb8c2
2 changed files with 77 additions and 3 deletions

View File

@ -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<Line> {
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<Rgba>,
alpha: f64,
base: Affine,

View File

@ -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<u8> {
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<Line>) -> 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));
}