From 4617bd0af8400dea18052269f93572d8b97d9d8e Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 23 Mar 2026 10:27:32 +0100 Subject: [PATCH] :wrench: WIP support skia pdf rendering --- .../src/app/main/data/exports/assets.cljs | 6 +- frontend/src/app/main/data/exports/wasm.cljs | 15 + frontend/src/app/render_wasm/api.cljs | 19 + render-wasm/Cargo.toml | 1 + render-wasm/src/main.rs | 16 + render-wasm/src/render.rs | 4 + render-wasm/src/render/fonts.rs | 1 + render-wasm/src/render/gpu_renderer.rs | 169 +++++ render-wasm/src/render/images.rs | 7 + render-wasm/src/render/pdf.rs | 71 ++ render-wasm/src/render/shape_renderer.rs | 37 + render-wasm/src/render/vector.rs | 697 ++++++++++++++++++ render-wasm/src/shapes/fonts.rs | 1 + render-wasm/src/state.rs | 4 + 14 files changed, 1046 insertions(+), 2 deletions(-) create mode 100644 render-wasm/src/render/gpu_renderer.rs create mode 100644 render-wasm/src/render/pdf.rs create mode 100644 render-wasm/src/render/shape_renderer.rs create mode 100644 render-wasm/src/render/vector.rs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 8ab85b5228..cefc4fe387 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -156,11 +156,13 @@ (defn request-simple-export [{:keys [export]}] (if (and (contains? cf/flags :wasm-export) - (contains? #{:jpeg :webp :png} (:type export))) + (contains? #{:jpeg :webp :png :pdf} (:type export))) (ptk/reify ::request-simple-export-wasm ptk/EffectEvent (effect [_ _ _] - (wasm.exports/export-image export))) + (case (:type export) + :pdf (wasm.exports/export-pdf export) + (wasm.exports/export-image export)))) (ptk/reify ::request-simple-export ptk/UpdateEvent diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index e0feb03132..ab2a198939 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -26,3 +26,18 @@ (dom/trigger-download-uri filename mtype url) (wapi/revoke-uri url) nil)) + +(defn export-pdf-uri + [{:keys [scale object-id]}] + (let [bytes (wasm.api/render-shape-pdf object-id (or scale 1)) + blob (wapi/create-blob bytes "application/pdf")] + (wapi/create-uri blob))) + +(defn export-pdf + [{:keys [suffix name] :as params}] + (let [url (export-pdf-uri params) + filename (str name (or suffix "") ".pdf")] + (dom/trigger-download-uri filename "application/pdf" url) + (wapi/revoke-uri url) + nil)) + diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 60b06f596e..2c594e80de 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1677,6 +1677,25 @@ (mem/free) result)) +(defn render-shape-pdf + [shape-id scale] + (let [buffer (uuid/get-u32 shape-id) + + offset + (h/call wasm/internal-module "_render_shape_pdf" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale) + + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 4) length)] + (mem/free) + result)) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index a26e798e13..d9eab1ca8c 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -31,6 +31,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ "textlayout", "binary-cache", "webp", + "pdf", ] } thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index cff52ad821..36aa3367b0 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -873,6 +873,22 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state_mut!(state, { + let data = state.render_shape_pdf(&id, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + fn main() { #[cfg(target_arch = "wasm32")] init_gl!(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 707523f626..e14ede32ae 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2,16 +2,20 @@ mod debug; mod fills; pub mod filters; mod fonts; +mod gpu_renderer; mod gpu_state; pub mod grid_layout; mod images; mod options; +pub mod pdf; mod shadows; +pub mod shape_renderer; mod strokes; mod surfaces; pub mod text; pub mod text_editor; mod ui; +mod vector; use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index d528d7b691..2c7a8ccc51 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -119,6 +119,7 @@ impl FontStore { pub fn get_emoji_font(&self, _size: f32) -> Option { None } + } fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider { diff --git a/render-wasm/src/render/gpu_renderer.rs b/render-wasm/src/render/gpu_renderer.rs new file mode 100644 index 0000000000..a80a447961 --- /dev/null +++ b/render-wasm/src/render/gpu_renderer.rs @@ -0,0 +1,169 @@ +use skia_safe::{self as skia}; + +use super::shape_renderer::ShapeRenderer; +use super::{fills, shadows, strokes, RenderState, SurfaceId}; +use crate::error::Result; +use crate::shapes::{Fill, Shape, Stroke, Type}; + +/// GPU render backend — thin wrapper around the existing surface-based +/// rendering modules (`fills`, `strokes`, `shadows`). +/// +/// This struct is not yet used in the hot path (`render_shape`), but it +/// implements [`ShapeRenderer`] so that adding a new trait method produces +/// a compile error here, forcing the GPU side to be updated alongside the +/// PDF backend. +#[allow(dead_code)] +pub struct GpuRenderer<'a> { + pub render_state: &'a mut RenderState, + pub fills_surface_id: SurfaceId, + pub strokes_surface_id: SurfaceId, + pub innershadows_surface_id: SurfaceId, + pub antialias: bool, + pub outset: Option, + pub fast_mode: bool, + pub scale: f32, +} + +impl ShapeRenderer for GpuRenderer<'_> { + fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> { + fills::render( + self.render_state, + shape, + fills, + self.antialias, + self.fills_surface_id, + self.outset, + ) + } + + fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { + strokes::render( + self.render_state, + shape, + strokes, + Some(self.strokes_surface_id), + self.antialias, + self.outset, + ) + } + + fn draw_drop_shadows(&mut self, _shape: &Shape) -> Result<()> { + // GPU handles drop shadows at tree traversal level + // (render_shape_tree_partial), not at the leaf level. + Ok(()) + } + + fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> { + if !self.fast_mode { + shadows::render_fill_inner_shadows( + self.render_state, + shape, + self.antialias, + self.innershadows_surface_id, + ); + } + Ok(()) + } + + fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> { + if !self.fast_mode { + shadows::render_stroke_inner_shadows( + self.render_state, + shape, + stroke, + self.antialias, + self.innershadows_surface_id, + )?; + } + Ok(()) + } + + fn draw_text(&mut self, shape: &Shape) -> Result<()> { + // GPU text rendering is orchestrated inline in render_shape + // (render.rs). This wrapper delegates to the same text module. + 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 = shape.image_filter(1.); + + super::text::render( + Some(self.render_state), + None, + shape, + &mut paragraph_builders, + Some(self.fills_surface_id), + None, + 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.render_state + .surfaces + .canvas_and_mark_dirty(self.fills_surface_id) + .concat(&svg_transform); + } + + if let Some(svg) = shape.svg.as_ref() { + svg.render( + self.render_state + .surfaces + .canvas_and_mark_dirty(self.fills_surface_id), + ); + } else { + let font_manager = + skia::FontMgr::from(self.render_state.fonts().font_provider().clone()); + if let Ok(dom) = skia::svg::Dom::from_str(&sr.content, font_manager) { + dom.render( + self.render_state + .surfaces + .canvas_and_mark_dirty(self.fills_surface_id), + ); + } + } + + Ok(()) + } + + fn apply_blur_layer(&mut self, shape: &Shape) -> bool { + use crate::shapes::{radius_to_sigma, BlurType}; + + 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 = skia::Paint::default(); + paint.set_image_filter(filter); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.render_state + .surfaces + .canvas(self.fills_surface_id) + .save_layer(&layer_rec); + true + } else { + false + } + } + + fn restore_blur_layer(&mut self) { + self.render_state + .surfaces + .canvas(self.fills_surface_id) + .restore(); + } +} diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 51bf9dbbe0..51bb5168be 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -210,6 +210,13 @@ impl ImageStore { } } + /// Returns a CPU-backed (non-texture) copy of the image, suitable for + /// drawing on Skia's PDF canvas which has no GPU context. + pub fn get_cpu_image(&mut self, id: &Uuid) -> Option { + let gpu_image = self.get(id)?.clone(); + gpu_image.make_non_texture_image(self.context.as_mut()) + } + fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> { let key = (*id, is_thumbnail); // Use entry API to mutate the HashMap in-place if needed diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs new file mode 100644 index 0000000000..5e0482c01c --- /dev/null +++ b/render-wasm/src/render/pdf.rs @@ -0,0 +1,71 @@ +use skia_safe as skia; + +use crate::error::Result; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::vector::{self, VectorTarget}; +use super::RenderState; + +/// Renders a shape tree to a PDF document and returns the raw PDF bytes. +/// +/// This is a dedicated vector-PDF render path that draws directly to a Skia +/// PDF canvas, bypassing the GPU surface system entirely. The result is a +/// true vector PDF — paths, text and fills are represented as PDF drawing +/// operations rather than rasterised bitmaps. Effects that are inherently +/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's +/// PDF backend, which is the expected behaviour. +pub fn render_to_pdf( + render_state: &mut RenderState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + let shape = tree + .get(id) + .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; + let bounds = shape.extrect(tree, scale); + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + + let mut pdf_bytes: Vec = Vec::new(); + + // Build PDF metadata + let metadata = skia::pdf::Metadata { + creator: "Penpot".to_string(), + producer: "Penpot (Skia PDF)".to_string(), + ..Default::default() + }; + + let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata)); + + // begin_page consumes the document and returns an OnPage state + let mut on_page = document.begin_page((page_w, page_h), None); + + { + let page_canvas = on_page.canvas(); + + // Set up coordinate space: scale and translate so the shape's top-left + // maps to the page origin. + page_canvas.scale((scale, scale)); + page_canvas.translate((-bounds.left(), -bounds.top())); + + // Render the shape tree depth-first onto the PDF canvas. + vector::render_tree( + render_state, + page_canvas, + id, + tree, + scale, + VectorTarget::Pdf, + )?; + } + + // end_page consumes OnPage and returns the open document + let document = on_page.end_page(); + // close finalises the PDF and flushes to the writer + document.close(); + + Ok(pdf_bytes) +} diff --git a/render-wasm/src/render/shape_renderer.rs b/render-wasm/src/render/shape_renderer.rs new file mode 100644 index 0000000000..ba6ea62699 --- /dev/null +++ b/render-wasm/src/render/shape_renderer.rs @@ -0,0 +1,37 @@ +use crate::error::Result; +use crate::shapes::{Fill, Shape, Stroke}; + +/// Trait that both GPU and PDF render backends must implement. +/// +/// Adding a method here produces a compile error until both backends +/// handle it, ensuring new rendering features are never silently +/// missing from PDF export. +pub trait ShapeRenderer { + /// Draw fills for a shape (solid, gradient, image). + fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()>; + + /// Draw strokes for a shape (inner, outer, center). + fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()>; + + /// Draw drop shadows (offset shadow behind the shape silhouette). + fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()>; + + /// Draw inner shadows on filled geometry. + fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()>; + + /// Draw inner shadows on stroked geometry. + fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()>; + + /// Render a text shape (fills, strokes, shadows — full text pipeline). + fn draw_text(&mut self, shape: &Shape) -> Result<()>; + + /// Render an SVG raw shape. + fn draw_svg(&mut self, shape: &Shape) -> Result<()>; + + /// Apply a layer blur effect. Returns `true` if a save_layer was pushed + /// (caller must call `restore_blur_layer`). + fn apply_blur_layer(&mut self, shape: &Shape) -> bool; + + /// Restore the layer pushed by `apply_blur_layer`. + fn restore_blur_layer(&mut self); +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs new file mode 100644 index 0000000000..bc772a83a9 --- /dev/null +++ b/render-wasm/src/render/vector.rs @@ -0,0 +1,697 @@ +use skia_safe::{self as skia, Canvas, Paint, RRect}; + +use crate::error::Result; +use crate::shapes::{ + merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type, +}; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::get_source_rect; +use super::shape_renderer::ShapeRenderer; +use super::text; +use super::RenderState; + +// --------------------------------------------------------------------------- +// VectorTarget — vector export backend selector +// --------------------------------------------------------------------------- + +/// Selects backend-specific behaviour in [`VectorRenderer`]. +/// +/// Currently only PDF is supported. The enum exists so that adding +/// future vector targets (e.g. SVG) only requires a new variant and +/// target-specific branches in `VectorRenderer`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VectorTarget { + Pdf, +} + +// --------------------------------------------------------------------------- +// VectorRenderer — implements ShapeRenderer for canvas-based vector export +// --------------------------------------------------------------------------- + +/// Canvas-based vector render backend (used by both PDF and SVG export). +/// Draws directly to a Skia canvas (CPU-only, no GPU surfaces). +/// Implements [`ShapeRenderer`] so that adding a new trait method produces +/// a compile error until the vector export path handles it. +pub(super) struct VectorRenderer<'a> { + canvas: &'a Canvas, + render_state: &'a mut RenderState, + scale: f32, + _target: VectorTarget, +} + +impl<'a> VectorRenderer<'a> { + pub fn new( + canvas: &'a Canvas, + render_state: &'a mut RenderState, + scale: f32, + target: VectorTarget, + ) -> Self { + Self { + canvas, + render_state, + scale, + _target: target, + } + } +} + +impl ShapeRenderer for VectorRenderer<'_> { + fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> { + if fills.is_empty() { + return Ok(()); + } + + // Handle image fills individually + let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); + if has_image_fills { + for fill in fills.iter().rev() { + match fill { + Fill::Image(image_fill) => { + draw_image_fill(self.render_state, self.canvas, shape, image_fill)?; + } + _ => { + let mut paint = fill.to_paint(&shape.selrect, true); + if let Some(filter) = shape.image_filter(1.) { + 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) = shape.image_filter(1.) { + paint.set_image_filter(filter); + } + + draw_shape_geometry(self.canvas, shape, &paint); + Ok(()) + } + + fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { + for stroke in strokes.iter().rev() { + draw_single_stroke(self.canvas, shape, stroke)?; + } + Ok(()) + } + + fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { + 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().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(()); + } + for shadow in shape.inner_shadows_visible() { + let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); + self.canvas + .save_layer(&skia::canvas::SaveLayerRec::default().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 = shape.image_filter(1.); + + text::render( + None, + Some(self.canvas), + shape, + &mut paragraph_builders, + None, + None, + blur_filter.as_ref(), + None, + None, + )?; + + // Strokes for text + let count_inner_strokes = shape.count_visible_inner_strokes(); + 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(), + count_inner_strokes, + None, + ); + text::render_with_bounds_outset( + None, + Some(self.canvas), + shape, + &mut stroke_paragraphs, + None, + 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( + None, + Some(self.canvas), + shape, + &mut shadow_paragraphs, + None, + 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.render_state.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 { + 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_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.canvas.save_layer(&layer_rec); + true + } else { + false + } + } + + fn restore_blur_layer(&mut self) { + self.canvas.restore(); + } +} + +// --------------------------------------------------------------------------- +// Tree traversal +// --------------------------------------------------------------------------- + +/// Depth-first render of the shape tree rooted at `id`. +pub(super) fn render_tree( + render_state: &mut RenderState, + canvas: &Canvas, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + let Some(element) = tree.get(id) else { + return Ok(()); + }; + + if element.hidden { + return Ok(()); + } + + match &element.shape_type { + Type::Group(group) => { + render_group( + render_state, + canvas, + element, + group.masked, + tree, + scale, + target, + )?; + } + Type::Frame(_) => { + render_frame(render_state, canvas, element, tree, scale, target)?; + } + _ => { + render_leaf(render_state, canvas, element, scale, target)?; + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +fn render_group( + render_state: &mut RenderState, + canvas: &Canvas, + element: &Shape, + masked: bool, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + // Apply transform + let center = element.center(); + let mut matrix = element.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + + canvas.save(); + canvas.concat(&matrix); + + // Layer for opacity / blend mode + 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()); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + if masked { + // Masked group: render all children except the last, then apply the + // mask child with DstIn blend. + let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + if let Some((mask_id, content_ids)) = children.split_last() { + // Save a layer for the mask composition + let paint = Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + + // Render content children + for child_id in content_ids { + render_tree(render_state, canvas, child_id, tree, scale, target)?; + } + + // Render mask with DstIn + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + canvas.save_layer(&mask_rec); + render_tree(render_state, canvas, mask_id, tree, scale, target)?; + canvas.restore(); // mask layer + + canvas.restore(); // composition layer + } + } else { + // Normal group: render children in order (forward = back-to-front for painter's algorithm) + let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(render_state, canvas, child_id, tree, scale, target)?; + } + } + + if needs_layer { + canvas.restore(); // opacity/blend layer + } + canvas.restore(); // transform + Ok(()) +} + +// --------------------------------------------------------------------------- +// Frames +// --------------------------------------------------------------------------- + +fn render_frame( + render_state: &mut RenderState, + canvas: &Canvas, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + // Apply transform + let center = element.center(); + let mut matrix = element.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + + canvas.save(); + canvas.concat(&matrix); + + 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_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + // Clip to frame bounds if clip_content + if element.clip_content { + clip_to_shape(canvas, element, false); + } + + // Draw the frame's own fills (background) + if !element.fills.is_empty() { + let mut renderer = VectorRenderer::new(canvas, render_state, scale, target); + renderer.draw_fills(element, &element.fills)?; + } + + // Render children (forward = back-to-front for painter's algorithm) + let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(render_state, canvas, child_id, tree, scale, target)?; + } + + // Strokes drawn after children for clipped frames (over children) + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + let mut renderer = VectorRenderer::new(canvas, render_state, scale, target); + renderer.draw_strokes(element, &visible_strokes)?; + } + + if needs_layer { + canvas.restore(); // opacity/blend layer + } + canvas.restore(); // transform + Ok(()) +} + +// --------------------------------------------------------------------------- +// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) +// --------------------------------------------------------------------------- + +fn render_leaf( + render_state: &mut RenderState, + canvas: &Canvas, + element: &Shape, + scale: f32, + target: VectorTarget, +) -> Result<()> { + let needs_layer = element.needs_layer(); + + // Compute transform + let center = element.center(); + let mut matrix = element.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + + 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_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + // Use VectorRenderer for all drawing operations via the ShapeRenderer trait. + let mut renderer = VectorRenderer::new(canvas, render_state, scale, target); + + // Layer blur (non-text shapes) + let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { + renderer.apply_blur_layer(element) + } else { + false + }; + + // Drop shadows + renderer.draw_drop_shadows(element)?; + + // Shape-type-specific rendering + match &element.shape_type { + Type::Text(_) => { + renderer.draw_text(element)?; + } + Type::SVGRaw(_) => { + renderer.draw_svg(element)?; + } + _ => { + // Rect, Circle, Path, Bool + renderer.draw_fills(element, &element.fills)?; + + // Inner shadows on fills + renderer.draw_fill_inner_shadows(element)?; + + // Strokes + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + renderer.draw_strokes(element, &visible_strokes)?; + + // Inner shadows on strokes (only if no fills, to avoid double-drawing) + if !element.has_fills() { + for stroke in &visible_strokes { + renderer.draw_stroke_inner_shadows(element, stroke)?; + } + } + } + } + } + + if blur_layer { + renderer.restore_blur_layer(); + } + + if needs_layer { + canvas.restore(); + } + + canvas.restore(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Private helpers (canvas-only, not part of the trait) +// --------------------------------------------------------------------------- + +fn draw_image_fill( + render_state: &mut RenderState, + canvas: &Canvas, + shape: &Shape, + image_fill: &crate::shapes::ImageFill, +) -> 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) = render_state.images.get_cpu_image(&image_fill.id()) else { + return Ok(()); + }; + + let size = image.dimensions(); + let container = &shape.selrect; + + let src_rect = get_source_rect(size, container, image_fill); + let dest_rect = container; + + canvas.save(); + + // Clip to shape + clip_to_shape(canvas, shape, true); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + + canvas.draw_image_rect_with_sampling_options( + &image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), + dest_rect, + render_state.sampling_options, + &paint, + ); + + canvas.restore(); + Ok(()) +} + +fn draw_single_stroke(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> Result<()> { + let is_open = shape.is_open(); + let kind = stroke.render_kind(is_open); + let paint = stroke.to_stroked_paint(is_open, &shape.selrect, shape.svg_attrs.as_ref(), true); + + match kind { + StrokeKind::Inner => { + // Inner stroke: clip to shape, draw with double width + canvas.save(); + clip_to_shape(canvas, shape, true); + draw_shape_geometry(canvas, shape, &paint); + canvas.restore(); + } + StrokeKind::Outer => { + // Outer stroke: use save_layer + clip difference to draw outside shape only + canvas.save(); + let layer_rec = skia::canvas::SaveLayerRec::default(); + canvas.save_layer(&layer_rec); + draw_shape_geometry(canvas, shape, &paint); + // Clear inside the shape to keep only outer part + let mut clear_paint = Paint::default(); + clear_paint.set_blend_mode(skia::BlendMode::Clear); + clear_paint.set_anti_alias(true); + let mut fill_paint = clear_paint; + fill_paint.set_style(skia::PaintStyle::Fill); + draw_shape_geometry(canvas, shape, &fill_paint); + canvas.restore(); // layer + canvas.restore(); + } + StrokeKind::Center => { + draw_shape_geometry(canvas, shape, &paint); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +/// Draws the shape's geometry (rect/rrect/oval/path) with the given paint. +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() { + if let Some(path_transform) = shape.to_path_transform() { + let transformed = path.make_transform(&path_transform); + canvas.draw_path(&transformed, paint); + } else { + canvas.draw_path(&path, paint); + } + } + } + _ => {} + } +} + +/// 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() { + if let Some(path_transform) = shape.to_path_transform() { + canvas.clip_path( + &path.make_transform(&path_transform), + skia::ClipOp::Intersect, + antialias, + ); + } else { + canvas.clip_path(&path, skia::ClipOp::Intersect, antialias); + } + } + } + _ => { + canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias); + } + } +} diff --git a/render-wasm/src/shapes/fonts.rs b/render-wasm/src/shapes/fonts.rs index 86ab5d3897..9399485095 100644 --- a/render-wasm/src/shapes/fonts.rs +++ b/render-wasm/src/shapes/fonts.rs @@ -33,6 +33,7 @@ impl FontFamily { pub fn alias(&self) -> String { format!("{}", self) } + } impl fmt::Display for FontFamily { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 9239b38eb4..c6603c52ba 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -111,6 +111,10 @@ impl State { .render_shape_pixels(id, &self.shapes, scale, timestamp) } + pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result> { + crate::render::pdf::render_to_pdf(&mut self.render_state, id, &self.shapes, scale) + } + pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> { // If zoom changed (e.g. interrupted zoom render followed by pan), the // tile index may be stale for the new viewport position. Rebuild the