🐛 Include board drop shadow in multi-element raster export (#12440)

Headless raster/PDF used the board selrect so overflowing children stayed
cropped, but that also clipped the board's own drop shadow. Use
export_root_bounds: own strokes/shadows/blur without joining children.

Closes #12388
This commit is contained in:
Alejandro Alonso 2026-10-19 15:29:40 +02:00 committed by GitHub
parent c99980327f
commit 655300e4d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 109 additions and 22 deletions

View File

@ -24,15 +24,9 @@ pub fn render_to_pdf(
let shape = tree
.get(id)
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
// Boards export at their own bounds (like the classic exporter, which
// screenshots exactly the board box): content overflowing a board lands
// outside the page/surface and is cropped. Other shapes keep the extended
// rect so their own shadows/blur are included.
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
shape.selrect()
} else {
shape.extrect(tree, scale)
};
// Boards crop overflowing children (classic board box) but keep their own
// strokes/shadows/blur. Other shapes use full extrect.
let bounds = shape.export_root_bounds(tree, scale);
let page_w = bounds.width() * scale;
let page_h = bounds.height() * scale;

View File

@ -79,15 +79,9 @@ pub fn render_to_raster(
let Some(shape) = tree.get(id) else {
return Ok((Vec::new(), 0, 0));
};
// Boards export at their own bounds (like the classic exporter, which
// screenshots exactly the board box): content overflowing a board lands
// outside the page/surface and is cropped. Other shapes keep the extended
// rect so their own shadows/blur are included.
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
shape.selrect()
} else {
shape.extrect(tree, scale)
};
// Boards crop overflowing children (classic board box) but keep their own
// strokes/shadows/blur. Other shapes use full extrect.
let bounds = shape.export_root_bounds(tree, scale);
let width = (bounds.width() * scale).ceil() as i32;
let height = (bounds.height() * scale).ceil() as i32;

View File

@ -3639,17 +3639,98 @@ fn group_with_empty_path(group_fills: Vec<Fill>) -> ShapesPool {
}
fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec<u8> {
let (bytes, _, _) = raster_png_size(pool, id);
bytes
}
fn raster_png_size(pool: &ShapesPool, id: Uuid) -> (Vec<u8>, i32, i32) {
let mut resources =
crate::render::RenderResources::try_new_headless().expect("headless resources");
let (bytes, _, _) = crate::render::raster::render_to_raster(
crate::render::raster::render_to_raster(
&mut resources,
&id,
pool,
1.0,
crate::render::raster::RasterFormat::Png,
)
.expect("raster export");
bytes
.expect("raster export")
}
/// #12388: multi-element (headless) raster must include a board's own drop
/// shadow, matching single-export sizing (spread 4 → +4px each side).
#[test]
fn raster_export_of_board_includes_own_drop_shadow_spread() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(0, 200, 0),
0.0,
4.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let (_, width, height) = raster_png_size(&pool, frame_id);
assert_eq!(
(width, height),
(208, 128),
"board raster must grow by drop-shadow spread, not stay at selrect"
);
}
/// Overflowing children stay cropped; only the board's own effects expand
/// the headless raster page (classic exporter board-box rule).
#[test]
fn raster_export_of_board_crops_overflowing_child_but_keeps_own_shadow() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(0, 200, 0),
0.0,
4.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
frame.add_child(child);
}
add_solid_rect(
&mut pool,
child,
frame_id,
(150.0, 80.0, 280.0, 200.0),
skia::Color::from_rgb(255, 0, 0),
);
let (_, width, height) = raster_png_size(&pool, frame_id);
assert_eq!(
(width, height),
(208, 128),
"overflowing child must not grow board raster; own shadow still must"
);
}
#[test]

View File

@ -1148,10 +1148,28 @@ impl Shape {
extrect
}
fn own_extrect_bounds(&self) -> Bounds {
pub(crate) fn own_extrect_bounds(&self) -> Bounds {
self.expand_own_bounds(self.own_base_bounds(), true)
}
/// Bounds for a headless export rooted at this shape.
///
/// Frames keep the classic board-box crop of overflowing children, but
/// still expand by their own strokes / drop shadows / layer blur so those
/// effects are not clipped (parity with single-export `extrect` for the
/// board itself). Other shapes use full [`Self::extrect`].
pub fn export_root_bounds(&self, tree: ShapesPoolRef, scale: f32) -> math::Rect {
if matches!(self.shape_type, Type::Frame(_)) {
let mut bounds = self.own_extrect_bounds();
if !self.transform.is_identity() {
bounds.transform_mut(&self.centered_transform());
}
bounds.to_rect()
} else {
self.extrect(tree, scale)
}
}
/// The shape's own geometry bounds, before stroke/shadow/blur margins.
fn own_base_bounds(&self) -> Bounds {
let shape = self;