use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, BlurType, Fill, Frame, ParagraphBuilderGroup, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, TextContent, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; use super::shape_renderer::ShapeRenderer; use super::text; use super::RenderResources; use super::RenderState; use super::{get_dest_rect, get_image_dest_rect, get_source_rect}; // --------------------------------------------------------------------------- // VectorRenderer — implements ShapeRenderer for canvas-based vector export // --------------------------------------------------------------------------- /// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces). pub(super) struct VectorRenderer<'a> { canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, /// When `true`, use PDF/GPU-friendly compositing (`merge_fills`, /// `save_layer` for outer strokes). When `false` (SVG export), avoid /// techniques that `SkSVGDevice` drops: draw fills individually and emit /// solid Inner/Outer strokes as filled outlines. compose_fills: bool, } impl<'a> VectorRenderer<'a> { pub fn new( canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, compose_fills: bool, ) -> Self { Self { canvas, shared, scale, compose_fills, } } /// Layer-blur paint filter for this backend. /// /// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops /// paint image-filters (the shape would vanish). Layer blur is re-emitted as /// a native SVG `` wrapper instead. fn layer_blur_filter(&self, shape: &Shape) -> Option { if !self.compose_fills { return None; } shape.image_filter(1.) } fn draw_vertical_text( &mut self, shape: &Shape, text_content: &TextContent, paragraph_builders: &mut [ParagraphBuilderGroup], blur_filter: Option<&skia::ImageFilter>, ) -> Result<()> { use crate::shapes::text_vertical; let bounds = text_content.bounds(); let drop_shadows = shape.drop_shadow_paints(); let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); let layout = (!drop_shadows.is_empty() || !strokes.is_empty()) .then(|| text_vertical::layout_for_box(text_content, bounds.height())); if let Some(layout) = &layout { for shadow in &drop_shadows { text_vertical::paint_drop_shadow( self.canvas, layout, &bounds, shape.vertical_align(), shadow, ); } } text::render_overlay_emoji( self.canvas, shape, paragraph_builders, None, blur_filter, None, None, )?; if let Some(layout) = &layout { let selrect = shape.selrect(); for stroke in strokes { text_vertical::paint_stroke( self.canvas, layout, &bounds, shape.vertical_align(), stroke, &selrect, blur_filter, ); } } Ok(()) } } impl ShapeRenderer for VectorRenderer<'_> { fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> { if fills.is_empty() { return Ok(()); } let blur_filter = self.layer_blur_filter(shape); let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); if !self.compose_fills || has_image_fills { // fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG). for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) => { draw_image_fill( self.shared, self.canvas, shape, image_fill, blur_filter.as_ref(), )?; } _ => { let mut paint = fill.to_paint(&shape.selrect, true); if let Some(filter) = blur_filter.clone() { paint.set_image_filter(filter); } draw_shape_geometry(self.canvas, shape, &paint); } } } return Ok(()); } let mut paint = merge_fills(fills, shape.selrect); paint.set_anti_alias(true); if let Some(filter) = blur_filter { paint.set_image_filter(filter); } draw_shape_geometry(self.canvas, shape, &paint); Ok(()) } fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { let svg_export = !self.compose_fills; for stroke in strokes.iter().rev() { draw_single_stroke( self.canvas, self.shared, self.scale, shape, stroke, svg_export, )?; } Ok(()) } fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { if let Some(filter) = shadow.get_drop_shadow_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_rec = skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint); self.canvas.save_layer(&layer_rec); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); draw_shape_geometry(self.canvas, shape, &fill_paint); self.canvas.restore(); } } Ok(()) } fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> { if !shape.has_fills() { return Ok(()); } let layer_bounds = shape.layer_bounds(); for shadow in shape.inner_shadows_visible() { let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref()); self.canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint), ); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); draw_shape_geometry(self.canvas, shape, &fill_paint); self.canvas.restore(); } Ok(()) } fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> { let is_open = shape.is_open(); for shadow in shape.inner_shadows_visible() { if let Some(filter) = shadow.get_inner_shadow_filter() { let mut paint = stroke.to_stroked_paint( is_open, &shape.selrect, shape.svg_attrs.as_ref(), true, ); paint.set_image_filter(filter); draw_shape_geometry(self.canvas, shape, &paint); } } Ok(()) } fn draw_text(&mut self, shape: &Shape) -> Result<()> { let Type::Text(text_content) = &shape.shape_type else { return Ok(()); }; let text_content = text_content.new_bounds(shape.selrect()); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let blur_filter = self.layer_blur_filter(shape); if text_content.is_vertical() { return self.draw_vertical_text( shape, &text_content, &mut paragraph_builders, blur_filter.as_ref(), ); } // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). let drop_shadows = shape.drop_shadow_paints(); if !drop_shadows.is_empty() { let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape .visible_strokes() .rev() .map(|stroke| { ( stroke.render_kind(false), text::stroke_paragraph_builder_group_from_text( &text_content, stroke, &shape.selrect(), Some(true), ) .0, ) }) .collect(); let layer_bounds = shape.layer_bounds(); for shadow_paint in &drop_shadows { self.canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(shadow_paint), ); text::render_overlay_emoji( self.canvas, shape, &mut shadow_paragraphs, None, blur_filter.as_ref(), None, None, )?; for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { if *kind == StrokeKind::Inner { // Inner stroke masked by the glyph fill (outset 0 here). let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true)); text::render_inner_stroke( None, Some(self.canvas), shape, stroke_paragraphs, &mut fill_builders, None, blur_filter.as_ref(), 0.0, None, )?; } else if *kind == StrokeKind::Outer { text::render_outer_stroke( None, Some(self.canvas), shape, stroke_paragraphs, None, blur_filter.as_ref(), 0.0, None, )?; } else { text::render_with_bounds_outset_overlay_emoji( self.canvas, shape, stroke_paragraphs, None, blur_filter.as_ref(), shadow_stroke_outset, None, None, )?; } } self.canvas.restore(); } } text::render_overlay_emoji( self.canvas, shape, &mut paragraph_builders, None, blur_filter.as_ref(), None, None, )?; // Strokes for text let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); for stroke in shape.visible_strokes().rev() { let (mut stroke_paragraphs, layer_opacity) = text::stroke_paragraph_builder_group_from_text( &text_content, stroke, &shape.selrect(), None, ); if stroke.render_kind(false) == StrokeKind::Inner { // Inner text stroke: clip to the glyph fill, else it bleeds out. let mut fill_builders = text_content.paragraph_builder_group_from_text(None); text::render_inner_stroke( None, Some(self.canvas), shape, &mut stroke_paragraphs, &mut fill_builders, None, blur_filter.as_ref(), stroke_blur_outset, layer_opacity, )?; } else if stroke.render_kind(false) == StrokeKind::Outer { text::render_outer_stroke( None, Some(self.canvas), shape, &mut stroke_paragraphs, None, blur_filter.as_ref(), stroke_blur_outset, layer_opacity, )?; } else { text::render_with_bounds_outset_overlay_emoji( self.canvas, shape, &mut stroke_paragraphs, None, blur_filter.as_ref(), stroke_blur_outset, None, layer_opacity, )?; } } // Inner shadows for text let inner_shadows: Vec<_> = shape.inner_shadows_visible().collect(); if !inner_shadows.is_empty() { let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); for shadow in &inner_shadows { let shadow_paint = shadow.get_inner_shadow_paint(true, blur_filter.as_ref()); text::render_overlay_emoji( self.canvas, shape, &mut shadow_paragraphs, Some(&shadow_paint), blur_filter.as_ref(), None, None, )?; } } Ok(()) } fn draw_svg(&mut self, shape: &Shape) -> Result<()> { let Type::SVGRaw(sr) = &shape.shape_type else { return Ok(()); }; if let Some(svg_transform) = shape.svg_transform() { self.canvas.concat(&svg_transform); } if let Some(svg) = shape.svg.as_ref() { svg.render(self.canvas); } else { let font_manager = skia::FontMgr::from(self.shared.fonts.font_provider().clone()); if let Ok(dom) = skia::svg::Dom::from_str(&sr.content, font_manager) { dom.render(self.canvas); } } Ok(()) } fn apply_blur_layer(&mut self, shape: &Shape) -> bool { if !self.compose_fills { return false; } let blur = match shape.blur { Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b, _ => return false, }; let sigma = radius_to_sigma(blur.value * self.scale); if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_bounds = shape.layer_bounds(); let layer_rec = skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint); self.canvas.save_layer(&layer_rec); true } else { false } } fn restore_blur_layer(&mut self) { self.canvas.restore(); } } // --------------------------------------------------------------------------- // Tree traversal // --------------------------------------------------------------------------- /// Options threaded through the whole traversal. Carries the export root/page /// (needed to re-render the backdrop for background blur) and the background /// blur strategy for the active canvas. struct TreeOpts<'a> { /// The exported root shape id — the backdrop for any shape is re-rendered /// from here. root: &'a Uuid, /// Root's export bounds (shape space); sizes/positions the offscreen /// backdrop surface to match the page. page: skia::Rect, /// `true` on a PDF canvas (which ignores Skia backdrop filters): background /// blur is produced by rendering the backdrop onto an offscreen raster /// surface and embedding the blurred result as an image. `false` on a raster /// canvas, where a plain Skia backdrop filter works directly. embed_bg_blur: bool, /// When rendering a backdrop, the shape whose own subtree must be omitted /// (so the blur samples only what is *behind* it). skip: Option<&'a Uuid>, /// Fills the root inherits from its ancestors; backdrops restart from it. root_fills: &'a [Fill], /// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`). inherited_fills: &'a [Fill], } impl<'a> TreeOpts<'a> { /// Options for `container`'s children: a group hands its fills down, a /// frame breaks the inheritance. fn for_children_of<'b>(&self, container: &'b Shape) -> TreeOpts<'b> where 'a: 'b, { let inherited_fills: &'b [Fill] = match container.shape_type { Type::Group(_) => &container.fills, _ => &[], }; TreeOpts { root: self.root, page: self.page, embed_bg_blur: self.embed_bg_blur, skip: self.skip, root_fills: self.root_fills, inherited_fills, } } } /// Depth-first render of the shape tree rooted at `id`. Used for raster export /// (PNG/webp), where background blur is applied with a direct Skia backdrop /// filter (raster surfaces support it). `page` must be the same bounds the /// caller used to size/translate its surface — the embedded background-blur /// backdrop is drawn device-aligned against it. pub(super) fn render_tree( shared: &mut RenderResources, canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, scale: f32, page: skia::Rect, ) -> Result<()> { render_tree_dispatch(shared, canvas, id, tree, scale, page, false) } /// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a /// rasterised image (the PDF backend ignores backdrop filters). pub(super) fn render_tree_pdf( shared: &mut RenderResources, canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, scale: f32, page: skia::Rect, ) -> Result<()> { render_tree_dispatch(shared, canvas, id, tree, scale, page, true) } #[allow(clippy::too_many_arguments)] fn render_tree_dispatch( shared: &mut RenderResources, canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, scale: f32, page: skia::Rect, embed_bg_blur: bool, ) -> Result<()> { let root_fills = tree .get(id) .map(|shape| shape.inherited_fills(tree)) .unwrap_or_default(); let opts = TreeOpts { root: id, page, embed_bg_blur, skip: None, root_fills: &root_fills, inherited_fills: &root_fills, }; render_tree_inner(shared, canvas, id, tree, scale, &opts) } fn render_tree_inner( shared: &mut RenderResources, canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, scale: f32, opts: &TreeOpts, ) -> Result<()> { // When re-rendering a backdrop, omit the blur shape's own subtree so it only // samples what is behind it. if opts.skip == Some(id) { return Ok(()); } let Some(element) = tree.get(id) else { return Ok(()); }; if element.hidden { return Ok(()); } // Background blur samples already-drawn content behind the shape, so it must // run before the shape (and its subtree) paints. SVGRaw is excluded, // matching the GPU path; text keeps only the glyph-alpha coverage (see the // text branches below), also matching the GPU path. if !matches!(element.shape_type, Type::SVGRaw(_)) { if let Some(blur) = element.visible_background_blur() { if blur.value > 0.0 { if opts.embed_bg_blur { render_background_blur_image(shared, canvas, element, tree, scale, opts)?; } else { render_background_blur_backdrop(canvas, element, blur.value * scale); } } } } match &element.shape_type { Type::Group(group) => { render_group(shared, canvas, element, group.masked, tree, scale, opts)?; } Type::Frame(_) => { render_frame(shared, canvas, element, tree, scale, opts)?; } // Leaf types listed explicitly (no `_`) so a new Type must be handled. Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Text(_) | Type::SVGRaw(_) => { let fills = if element.inherits_fills() { opts.inherited_fills } else { element.fills.as_slice() }; render_leaf(shared, canvas, element, fills, scale)?; } } Ok(()) } // --------------------------------------------------------------------------- // Background blur // --------------------------------------------------------------------------- /// Background blur on a canvas that supports Skia backdrop filters (raster). /// Blurs the current device contents within the shape silhouette and stamps the /// result back with `Src` — or, for text, keeps it only under the glyph/stroke /// alpha via a `DstIn` mask (mirrors the GPU path). `sigma_radius` is the blur /// radius already multiplied by the export scale. fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) { let sigma = radius_to_sigma(sigma_radius); let Some(blur_filter) = skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) else { return; }; let matrix = shape.centered_transform(); canvas.save(); canvas.concat(&matrix); if matches!(shape.shape_type, Type::Text(_)) { // Text has no closed geometry to clip with: blur the backdrop inside // the shape rect (outset by the max outward stroke reach so the mask's // stroke coverage isn't cut off), then keep the blurred result only // where the opaque glyph/stroke mask is, via DstIn. let mut clip_rect = shape.selrect; let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); if stroke_outset > 0.0 { clip_rect.outset((stroke_outset, stroke_outset)); } canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true); // Blur in device space (sigma already includes the export scale); the // clip survives reset_matrix. Remember the full transform to restore it // for painting the mask. let local_to_device = canvas.local_to_device(); canvas.reset_matrix(); // SrcOver composite (NOT Src): Src would clear the backdrop outside // the glyphs within the clip rect; SrcOver + DstIn mask leaves the // unmasked backdrop untouched. let layer_rec = skia::canvas::SaveLayerRec::default() .backdrop(&blur_filter) .backdrop_tile_mode(skia::TileMode::Clamp); canvas.save_layer(&layer_rec); canvas.set_matrix(&local_to_device); // Keep the blurred backdrop only where the glyphs/strokes are. let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); let mask_layer_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); canvas.save_layer(&mask_layer_rec); text::paint_text_mask(canvas, shape); canvas.restore(); // mask layer canvas.restore(); // blur layer canvas.restore(); // clip + transform return; } // When strokes extend beyond the fill geometry (center/outer), expand the // clip with the stroke coverage so the backdrop is also blurred under the // stroke (mirrors the GPU path). let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open()); if stroke_outset > 0.0 { let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset); canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); } else { clip_to_shape(canvas, shape, true); } // Apply the blur in device space (sigma already includes the export scale); // the clip, set with the full transform, survives reset_matrix. canvas.reset_matrix(); let mut paint = Paint::default(); paint.set_blend_mode(skia::BlendMode::Src); let layer_rec = skia::canvas::SaveLayerRec::default() .backdrop(&blur_filter) .backdrop_tile_mode(skia::TileMode::Clamp) .paint(&paint); canvas.save_layer(&layer_rec); canvas.restore(); // composite the blurred-backdrop layer canvas.restore(); // pop the clip + transform } /// Background blur for a PDF canvas (backdrop filters unsupported): render the /// backdrop — the whole page minus this shape's own subtree — onto an offscreen /// raster surface, blur it, and embed the result as an image clipped to the /// shape. The rest of the page stays vector. /// /// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted /// *after* it. For content stacked on top of the blur shape the foreground would /// bleed into the blur; correct for the common case (nothing above the panel). fn render_background_blur_image( shared: &mut RenderResources, canvas: &Canvas, shape: &Shape, tree: ShapesPoolRef, scale: f32, opts: &TreeOpts, ) -> Result<()> { let bounds = opts.page; let width = (bounds.width() * scale).ceil() as i32; let height = (bounds.height() * scale).ceil() as i32; if width <= 0 || height <= 0 { return Ok(()); } // Render the backdrop into an offscreen raster surface, in the same // coordinate space as the PDF page (see `render/pdf.rs`). let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else { return Ok(()); }; { let oc = surface.canvas(); oc.clear(skia::Color::TRANSPARENT); oc.scale((scale, scale)); oc.translate((-bounds.left(), -bounds.top())); let sub = TreeOpts { root: opts.root, page: opts.page, embed_bg_blur: false, skip: Some(&shape.id), root_fills: opts.root_fills, inherited_fills: opts.root_fills, }; render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?; } let image = surface.image_snapshot(); // Bake the blur into a raster bitmap. The PDF backend ignores image filters // at draw time (same limitation as backdrop filters), so we must blur on a // raster surface — where filters work — and embed the pre-blurred result. let is_text = matches!(shape.shape_type, Type::Text(_)); let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale); let blurred = { let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else { return Ok(()); }; let bc = blur_surface.canvas(); bc.clear(skia::Color::TRANSPARENT); let mut paint = Paint::default(); if let Some(filter) = skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) { paint.set_image_filter(filter); } bc.draw_image(&image, (0.0, 0.0), Some(&paint)); if is_text { // Text has no closed geometry to clip with on the PDF canvas, and // the PDF backend can't express DstIn either — so bake the // glyph/stroke alpha mask into the raster bitmap here (where blend // modes work) and embed the already-masked result. let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); bc.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); // Same page-space transform used to render the backdrop above. bc.scale((scale, scale)); bc.translate((-bounds.left(), -bounds.top())); bc.concat(&shape.centered_transform()); text::paint_text_mask(bc, shape); bc.restore(); } blur_surface.image_snapshot() }; let matrix = shape.centered_transform(); canvas.save(); canvas.concat(&matrix); if is_text { // Mask is already baked into the bitmap; the clip only bounds it to // the shape rect (outset by the max outward stroke reach so stroke // coverage isn't cut off). let mut clip_rect = shape.selrect; let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); if stroke_outset > 0.0 { clip_rect.outset((stroke_outset, stroke_outset)); } canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true); } else { // Expand the clip with the stroke coverage when strokes reach beyond // the fill geometry (mirrors the GPU path). let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open()); if stroke_outset > 0.0 { let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset); canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); } else { clip_to_shape(canvas, shape, true); } } // Draw the pre-blurred full-page bitmap in device space (1 image px per // device unit) so it aligns with the page regardless of the shape transform. canvas.reset_matrix(); canvas.draw_image(&blurred, (0.0, 0.0), None); canvas.restore(); Ok(()) } // --------------------------------------------------------------------------- // Groups // --------------------------------------------------------------------------- fn render_group( shared: &mut RenderResources, canvas: &Canvas, element: &Shape, masked: bool, tree: ShapesPoolRef, scale: f32, opts: &TreeOpts, ) -> Result<()> { // A group has no geometry of its own and does NOT propagate a transform to // its children: child shapes are stored in absolute coordinates and each // applies its own `centered_transform`. (Concatenating the group transform // here would double-apply it to children — visible on rotated/nested groups.) canvas.save(); // Group drop shadow: subtree silhouette, below the opacity/clip layer. render_container_drop_shadows(shared, canvas, element, tree, scale, false, opts)?; // Layer for opacity / blend mode (and group-level layer blur) let needs_layer = element.needs_layer(); if needs_layer { let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); if let Some(blur) = element .blur .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) { let sigma = radius_to_sigma(blur.value * scale); if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { paint.set_image_filter(filter); } } let layer_bounds = element.extrect(tree, scale); let layer_rec = skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint); canvas.save_layer(&layer_rec); } let children: Vec = element.children_ids_iter_forward(false).copied().collect(); let child_opts = opts.for_children_of(element); if masked { // Mirror the GPU mask: render all children (including the mask shape) // as content, then re-draw the mask silhouette (the group's first child) // with DstIn to clip everything to it. let paint = Paint::default(); let subtree_bounds = element.extrect(tree, scale); canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&subtree_bounds) .paint(&paint), ); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; } if let Some(mask_id) = element.mask_id() { let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&subtree_bounds) .paint(&mask_paint), ); render_tree_inner(shared, canvas, mask_id, tree, scale, &child_opts)?; canvas.restore(); // mask layer } canvas.restore(); // composition layer } else { for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; } } if needs_layer { canvas.restore(); // opacity/blend layer } canvas.restore(); Ok(()) } // --------------------------------------------------------------------------- // Frames // --------------------------------------------------------------------------- fn render_frame( shared: &mut RenderResources, canvas: &Canvas, element: &Shape, tree: ShapesPoolRef, scale: f32, opts: &TreeOpts, ) -> Result<()> { // A frame's own geometry (background, clip, strokes) is placed by its // `centered_transform`, but — like groups — it does NOT propagate that // transform to its children, which are stored in absolute coordinates. So // the transform is applied only around the frame's own draws; children are // rendered untransformed. let matrix = element.centered_transform(); canvas.save(); // Frame drop shadow: background + subtree silhouette, below the clip layer // so it extends outside the frame bounds. render_container_drop_shadows(shared, canvas, element, tree, scale, true, opts)?; let needs_layer = element.needs_layer(); if needs_layer { let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); // Frame-level layer blur if let Some(blur) = element .blur .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) { let sigma = radius_to_sigma(blur.value * scale); if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { paint.set_image_filter(filter); } } let layer_bounds = element.extrect(tree, scale); let layer_rec = skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint); canvas.save_layer(&layer_rec); } // Clip fills + children only. Strokes render outside the content clip so // outer/center strokes are not trimmed (same as GPU render_shape_exit). canvas.save(); if element.clip_content { canvas.concat(&matrix); clip_to_frame_content(canvas, element, scale); if let Some(inverse) = matrix.invert() { canvas.concat(&inverse); } } // Frame's own fills (background) + inner shadows, in the frame's space. if !element.fills.is_empty() { canvas.save(); canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); } // Children (absolute coords, no frame transform). let children: Vec = element.children_ids_iter_forward(false).copied().collect(); let child_opts = opts.for_children_of(element); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; } canvas.restore(); // content clip // Strokes over children, outside the frame content clip. let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { canvas.save(); canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_strokes(element, &visible_strokes)?; canvas.restore(); } if needs_layer { canvas.restore(); // opacity/blend layer } canvas.restore(); Ok(()) } /// Drop shadows for a container: render the subtree into a drop-shadow filter /// layer (its alpha becomes the shadow). `draw_fills` includes the frame /// background in the silhouette. fn render_container_drop_shadows( shared: &mut RenderResources, canvas: &Canvas, element: &Shape, tree: ShapesPoolRef, scale: f32, draw_fills: bool, opts: &TreeOpts, ) -> Result<()> { let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_filter() else { continue; }; let mut paint = Paint::default(); paint.set_image_filter(filter); canvas.save_layer( &skia::canvas::SaveLayerRec::default() .bounds(&subtree_bounds) .paint(&paint), ); if draw_fills && !element.fills.is_empty() { let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; } let children: Vec = element.children_ids_iter_forward(false).copied().collect(); let child_opts = opts.for_children_of(element); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; } canvas.restore(); } Ok(()) } // --------------------------------------------------------------------------- // Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) // --------------------------------------------------------------------------- fn render_leaf( shared: &mut RenderResources, canvas: &Canvas, element: &Shape, fills: &[Fill], scale: f32, ) -> Result<()> { let needs_layer = element.needs_layer(); let matrix = element.centered_transform(); canvas.save(); canvas.concat(&matrix); // Layer for opacity/blend if needs_layer { let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); let layer_bounds = element.layer_bounds(); let layer_rec = skia::canvas::SaveLayerRec::default() .bounds(&layer_bounds) .paint(&paint); canvas.save_layer(&layer_rec); } let mut renderer = VectorRenderer::new(canvas, shared, scale, true); // Layer blur (non-text shapes) let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { renderer.apply_blur_layer(element) } else { false }; renderer.draw_drop_shadows(element)?; render_leaf_content(&mut renderer, element, fills)?; if blur_layer { renderer.restore_blur_layer(); } if needs_layer { canvas.restore(); } canvas.restore(); Ok(()) } /// Single source of truth for leaf content draw order/gating (fills, inner /// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer /// blur are excluded — they wrap the content and are sequenced per backend. pub(super) fn render_leaf_content( renderer: &mut R, shape: &Shape, fills: &[Fill], ) -> Result<()> { match &shape.shape_type { Type::Text(_) => renderer.draw_text(shape)?, Type::SVGRaw(_) => renderer.draw_svg(shape)?, // Group/Frame never reach here; listed so a new Type must be handled. Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Group(_) | Type::Frame(_) => { renderer.draw_fills(shape, fills)?; renderer.draw_fill_inner_shadows(shape)?; let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); if !visible_strokes.is_empty() { renderer.draw_strokes(shape, &visible_strokes)?; // Stroke inner shadows only when there are no fills (matches GPU). if !shape.has_fills() { for stroke in &visible_strokes { renderer.draw_stroke_inner_shadows(shape, stroke)?; } } } } } Ok(()) } // --------------------------------------------------------------------------- // Private helpers (canvas-only) // --------------------------------------------------------------------------- fn draw_image_fill( shared: &mut RenderResources, canvas: &Canvas, shape: &Shape, image_fill: &crate::shapes::ImageFill, blur_filter: Option<&skia::ImageFilter>, ) -> Result<()> { // Use a CPU-backed image copy — GPU-backed images can't be drawn // on the PDF canvas which has no GPU context. let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else { return Ok(()); }; let size = image.dimensions(); let container = &shape.selrect; let dest_rect = get_image_dest_rect(container, image_fill); let src_rect = get_source_rect(size, &dest_rect, image_fill); canvas.save(); // Clip to shape clip_to_shape(canvas, shape, true); let mut paint = Paint::default(); paint.set_anti_alias(true); if let Some(filter) = blur_filter { paint.set_image_filter(filter.clone()); } canvas.draw_image_rect_with_sampling_options( &image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, shared.sampling_options, &paint, ); canvas.restore(); Ok(()) } fn draw_single_stroke( canvas: &Canvas, shared: &mut RenderResources, scale: f32, shape: &Shape, stroke: &Stroke, svg_export: bool, ) -> Result<()> { // Image-fill strokes: the stroke masks the visible area of the image. if let Fill::Image(image_fill) = &stroke.fill { return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill); } // Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer, // PathEffect stamps for dots/dashes): expand to a filled outline instead. // Solid Center stays on the shared stroke path. if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) { return Ok(()); } draw_stroke_geometry(canvas, scale, shape, stroke, false); Ok(()) } /// Shape path in local coords for SVG stroke outline expansion. fn svg_stroke_shape_path(shape: &Shape) -> Option { match &shape.shape_type { Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))), Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))), Type::Circle => Some(Path::new(circle_segments_local(shape))), Type::Path(_) | Type::Bool(_) => { let path = shape.shape_type.path()?; let mut local = path.clone(); if let Some(t) = shape.to_path_transform() { local.transform(&t); } Some(local) } Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None, } } fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option { let kind = stroke.render_kind(is_open); match stroke.style { StrokeStyle::Solid => match kind { // Solid Center already serializes as a native SVG stroke. StrokeKind::Center => None, StrokeKind::Inner | StrokeKind::Outer => { if is_open { None } else { Some(true) } } }, // PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them. StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false), } } /// Draws a stroke as a filled path outline for SVG export. /// /// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including /// Center and open paths, which force Center). Returns `true` when handled. fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool { let is_open = shape.is_open(); // Per-side rect/frame strokes already expand to an evenodd band in // `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width. if stroke.per_side_widths().is_some() && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) { return false; } let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else { return false; }; let Some(shape_path) = svg_stroke_shape_path(shape) else { return false; }; let Some(outline) = stroke_to_path( stroke, &shape_path, None, &shape.selrect, shape.svg_attrs.as_ref(), solid_outline, ) else { return false; }; let mut paint = stroke.fill.to_paint(&shape.selrect, true); paint.set_style(skia::PaintStyle::Fill); paint.set_anti_alias(true); canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); // Caps are already part of the outline (`stroke_to_path` unions them in), // so they must not be overlaid again: a second draw would double the // alpha of translucent strokes. true } /// Opaque stroke region for SVG clipPath silhouettes. /// /// Expands every alignment (including solid Center) to a filled outline so we /// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw. pub(super) fn paint_svg_stroke_silhouette( canvas: &Canvas, shape: &Shape, stroke: &Stroke, scale: f32, ) -> bool { if stroke.per_side_widths().is_some() && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) { let corners = shape.shape_type.corners(); let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true); paint.set_shader(None); paint.set_color(skia::Color::BLACK); super::strokes::draw_stroke_on_rect( canvas, stroke, &shape.selrect, &corners, &paint, scale, None, None, Stroke::per_side_profile(shape.visible_strokes()), true, ); return true; } let Some(shape_path) = svg_stroke_shape_path(shape) else { return false; }; // Expand Center too: a native stroke attribute cannot clip an image. let solid_outline = matches!(stroke.style, StrokeStyle::Solid); let Some(outline) = stroke_to_path( stroke, &shape_path, None, &shape.selrect, shape.svg_attrs.as_ref(), solid_outline, ) else { return false; }; let mut paint = Paint::default(); paint.set_style(skia::PaintStyle::Fill); paint.set_anti_alias(true); paint.set_color(skia::Color::BLACK); canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); true } /// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle /// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width /// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette. fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Stroke, opaque: bool) { let svg_attrs = shape.svg_attrs.as_ref(); let is_open = shape.is_open(); match &shape.shape_type { shape_type @ (Type::Rect(_) | Type::Frame(_)) => { let corners = shape_type.corners(); let mut paint = stroke.to_paint(&shape.selrect, svg_attrs, true); if opaque { paint.set_shader(None); paint.set_color(skia::Color::BLACK); } super::strokes::draw_stroke_on_rect( canvas, stroke, &shape.selrect, &corners, &paint, scale, None, None, Stroke::per_side_profile(shape.visible_strokes()), true, ); } Type::Circle => { let mut paint = stroke.to_paint(&shape.selrect, svg_attrs, true); if opaque { paint.set_shader(None); paint.set_color(skia::Color::BLACK); } super::strokes::draw_stroke_on_circle( canvas, stroke, &shape.selrect, &paint, scale, None, None, true, ); } Type::Path(_) | Type::Bool(_) => { let mut paint = stroke.to_stroked_paint(is_open, &shape.selrect, svg_attrs, true); if opaque { paint.set_shader(None); paint.set_color(skia::Color::BLACK); } draw_stroke_kind_aware(canvas, shape, stroke, &paint); if is_open { if let Some(cap_path) = transformed_skia_path(shape) { super::strokes::handle_stroke_caps( &cap_path, stroke, canvas, is_open, &paint, None, true, ); } } } // Text strokes go through draw_text; groups/svg never carry strokes. Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {} } } /// Draws a stroked `paint` honoring the stroke kind (inner clip / outer /// layer+clear / center). fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint: &Paint) { match stroke.render_kind(shape.is_open()) { StrokeKind::Inner => { canvas.save(); clip_to_shape(canvas, shape, true); draw_shape_geometry(canvas, shape, paint); canvas.restore(); } StrokeKind::Outer => { canvas.save(); let layer_bounds = shape.layer_bounds(); canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); draw_shape_geometry(canvas, shape, paint); let mut clear_paint = Paint::default(); clear_paint.set_blend_mode(skia::BlendMode::Clear); clear_paint.set_anti_alias(true); clear_paint.set_style(skia::PaintStyle::Fill); draw_shape_geometry(canvas, shape, &clear_paint); canvas.restore(); // layer canvas.restore(); } StrokeKind::Center => { draw_shape_geometry(canvas, shape, paint); } } } /// Image-filled stroke: draw the stroke silhouette in a layer, then paint the /// CPU image over it with `SrcIn` so only the stroke area shows the image. fn draw_image_stroke( canvas: &Canvas, shared: &mut RenderResources, scale: f32, shape: &Shape, stroke: &Stroke, image_fill: &crate::shapes::ImageFill, ) -> Result<()> { let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else { return Ok(()); }; let size = image.dimensions(); let container = shape.selrect; canvas.save(); let layer_bounds = shape.layer_bounds(); canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); // Opaque stroke silhouette; the SrcIn image draw below fills it. draw_stroke_geometry(canvas, scale, shape, stroke, true); let mut image_paint = Paint::default(); image_paint.set_blend_mode(skia::BlendMode::SrcIn); image_paint.set_anti_alias(true); if let Some(filter) = shape.image_filter(1.) { image_paint.set_image_filter(filter); } let src_rect = get_source_rect(size, &container, image_fill); let dest_rect = get_dest_rect(&container, stroke.delta()); canvas.draw_image_rect_with_sampling_options( &image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, shared.sampling_options, &image_paint, ); canvas.restore(); // layer canvas.restore(); Ok(()) } fn transformed_skia_path(shape: &Shape) -> Option { if !matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) { return None; } shape.get_skia_path() } // --------------------------------------------------------------------------- // Geometry helpers // --------------------------------------------------------------------------- /// Draws the shape's geometry (rect/rrect/oval/path) with the given paint. pub(super) fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { if let Some(corners) = shape.shape_type.corners() { let rrect = RRect::new_rect_radii(shape.selrect, &corners); canvas.draw_rrect(rrect, paint); } else { canvas.draw_rect(shape.selrect, paint); } } Type::Circle => { canvas.draw_oval(shape.selrect, paint); } Type::Path(_) | Type::Bool(_) => { if let Some(path) = shape.get_skia_path() { canvas.draw_path(&path, paint); } } // Not plain geometry (drawn via draw_text / draw_svg / traversal). Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {} } } /// Clips the canvas to a frame's content bounds, outset by ~0.5 device px so /// the hard (non-AA) clip edge doesn't shave off edge pixels and leave a seam. fn clip_to_frame_content(canvas: &Canvas, shape: &Shape, scale: f32) { let outset = 0.5 / scale.max(1e-6); let mut rect = shape.selrect; rect.outset((outset, outset)); match shape.shape_type.corners() { Some(corners) => { let rrect = RRect::new_rect_radii(rect, &corners); canvas.clip_rrect(rrect, skia::ClipOp::Intersect, false); } None => { canvas.clip_rect(rect, skia::ClipOp::Intersect, false); } } } /// Clips the canvas to the shape's geometry. fn clip_to_shape(canvas: &Canvas, shape: &Shape, antialias: bool) { let container = &shape.selrect; match &shape.shape_type { Type::Rect(Rect { corners: Some(corners), }) | Type::Frame(Frame { corners: Some(corners), .. }) => { let rrect = RRect::new_rect_radii(*container, corners); canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias); } Type::Rect(_) | Type::Frame(_) => { canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias); } Type::Circle => { let mut pb = skia::PathBuilder::new(); pb.add_oval(*container, None, None); canvas.clip_path(&pb.detach(), skia::ClipOp::Intersect, antialias); } Type::Path(_) | Type::Bool(_) => { if let Some(path) = shape.get_skia_path() { canvas.clip_path(&path, skia::ClipOp::Intersect, antialias); } } // Fallback to the bounding rect. Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => { canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias); } } }