diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index b5b7366509..cf53731c30 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -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; diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 740d20b8d9..52dd798006 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -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; diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 7557eebb7c..0a047e8d07 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3639,17 +3639,98 @@ fn group_with_empty_path(group_fills: Vec) -> ShapesPool { } fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec { + let (bytes, _, _) = raster_png_size(pool, id); + bytes +} + +fn raster_png_size(pool: &ShapesPool, id: Uuid) -> (Vec, 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] diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3f75443a16..e766b723d3 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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;