From 6ebefa2c165c2a94ad651514e9aebdd37d9b6668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 11 Jun 2026 10:41:21 +0200 Subject: [PATCH] :sparkles: Add single-file export to PDF using the WebGL render (#9860) --- .../src/app/main/data/exports/assets.cljs | 54 +- frontend/src/app/main/data/exports/wasm.cljs | 15 + frontend/src/app/render_wasm/api.cljs | 19 + render-wasm/Cargo.toml | 1 + render-wasm/README.md | 1 + render-wasm/docs/rendering_architecture.md | 131 +++ render-wasm/src/main.rs | 16 + render-wasm/src/render.rs | 3 + render-wasm/src/render/images.rs | 5 + render-wasm/src/render/pdf.rs | 55 + render-wasm/src/render/shape_renderer.rs | 21 + render-wasm/src/render/strokes.rs | 6 +- render-wasm/src/render/text.rs | 250 ++++- render-wasm/src/render/vector.rs | 957 ++++++++++++++++++ render-wasm/src/shapes.rs | 9 + render-wasm/src/state.rs | 4 + 16 files changed, 1515 insertions(+), 32 deletions(-) create mode 100644 render-wasm/docs/rendering_architecture.md 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 7dadba3d02..6a2105b737 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -163,31 +163,46 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) +;; TODO: Remove once we support WASM SVG export +(def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) + +(defn- wasm-export-enabled? + "WASM export is available: the flag is set AND render-wasm is active for the + current file. When render-wasm is inactive its shape tree isn't loaded, so a + client-side WASM render would crash." + [state] + (and (contains? cf/flags :wasm-export) + (features/active-feature? state "render-wasm/v1"))) + +(defn- use-wasm-export? + "Whether to take the client-side WASM export path for `export`." + [state export] + (and (wasm-export-enabled? state) + (contains? wasm-export-types (:type export)))) + (defn request-simple-export [{:keys [export]}] - (if (and (contains? cf/flags :wasm-export) - (contains? #{:jpeg :webp :png} (:type export))) - (ptk/reify ::request-simple-export-wasm - ptk/EffectEvent - (effect [_ _ _] - (wasm.exports/export-image export))) + (ptk/reify ::request-simple-export + ptk/UpdateEvent + (update [_ state] + (cond-> state + (not (use-wasm-export? state export)) + (update :export assoc :in-progress true :id uuid/zero))) - (ptk/reify ::request-simple-export - ptk/UpdateEvent - (update [_ state] - (update state :export assoc :in-progress true :id uuid/zero)) - - ptk/WatchEvent - (watch [_ state _] + ptk/WatchEvent + (watch [_ state _] + (if (use-wasm-export? state export) + (do + (case (:type export) + :pdf (wasm.exports/export-pdf export) + (wasm.exports/export-image export)) + (rx/empty)) (let [profile-id (:profile-id state) params {:exports [export] :profile-id profile-id :cmd :export-shapes :wait true - :is-wasm - (and - (features/active-feature? state "render-wasm/v1") - (contains? cf/flags :wasm-export))}] + :is-wasm (wasm-export-enabled? state)}] (rx/concat (rx/of ::dwp/force-persist) @@ -221,10 +236,7 @@ :cmd cmd :profile-id profile-id :force-multiple true - :is-wasm - (and - (features/active-feature? state "render-wasm/v1") - (contains? cf/flags :wasm-export))} + :is-wasm (wasm-export-enabled? state)} (some? name) (assoc :name name)) diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index e0feb03132..b91f461795 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) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) + diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 57748e48c4..a1a0a2f6bf 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2459,6 +2459,25 @@ (mem/free) {:x x :y y :width w :height h})))) +(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 77c4c10715..15f8f39335 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -32,6 +32,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/README.md b/render-wasm/README.md index e433b27cd1..b1010bb70c 100644 --- a/render-wasm/README.md +++ b/render-wasm/README.md @@ -52,6 +52,7 @@ cd penpot/render-wasm ## Technical documentation +- [Rendering Architecture (Live vs Vector/PDF)](./docs/rendering_architecture.md) - [Serialization](./docs/serialization.md) - [Tile Rendering](./docs/tile_rendering.md) - [Texts](./docs/texts.md) diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md new file mode 100644 index 0000000000..709c882900 --- /dev/null +++ b/render-wasm/docs/rendering_architecture.md @@ -0,0 +1,131 @@ +# Rendering Architecture: Live (GPU) vs Vector (PDF) Export + +Penpot's WASM engine has **two render paths** that must produce the same picture: + +| Path | Purpose | Backend | Code | +|------|---------|---------|------| +| **Live / GPU** | On-screen workspace, thumbnails, PNG export | WebGL surfaces + Skia | `render.rs::render_shape` (+ `render/{fills,strokes,shadows,text,...}.rs`) | +| **Vector** | True vector PDF (and future SVG) export | Single CPU Skia canvas (no GPU) | `render/vector.rs` → `render/pdf.rs` | + +They share the same shape tree and the same low-level drawing primitives, but +compose them differently. Keeping them in sync is the whole game — see +[Parity guards](#parity-guards). + +## Why two paths? + +The live path draws each shape into **many intermediate GPU surfaces** (fills, +strokes, shadows, …) and composites them. Compositing rasterises. That is fine +for the screen and for PNG, but a PDF made that way would be a bitmap. + +The vector path bypasses the GPU surface system and draws **directly onto a +Skia PDF canvas**, so paths, text and fills come out as real PDF vector +operations. Only inherently pixel-based effects (blur, blurred shadows) are +rasterised — by Skia's PDF backend, by design. + +## The two pipelines + +```mermaid +flowchart TB + tree["Shape tree (ShapesPool)"] + + subgraph GPU["Live / GPU path — render.rs"] + direction TB + g0["render_shape(shape)"] + g1["fast_mode? can_render_directly?
tiles, clip stacks, nested fills/blurs"] + gF["fills::render → Surface::Fills"] + gS["strokes::render → Surface::Strokes"] + gI["shadows::* → Surface::InnerShadows"] + gD["drop shadows (tree level)
→ Surface::DropShadows"] + gC["draw_shape_surface_stack_into
composite surfaces → final z-order"] + g0 --> g1 --> gF --> gS --> gI --> gC + gD --> gC + end + + subgraph SHARED["Shared primitives (one source of truth)"] + p1["draw_stroke_on_rect / draw_stroke_on_circle"] + p2["handle_stroke_caps (arrows, markers)"] + p3["render_inner_stroke / render_overlay_emoji (text)"] + end + + subgraph VEC["Vector path — render/vector.rs"] + direction TB + v0["render_to_pdf → render_tree(shape)"] + v1["render_group / render_frame / render_leaf
concat centered_transform, save_layer for opacity/blur"] + v2["draw_drop_shadows (inline)"] + v3["render_leaf_content<R: ShapeRenderer>
fills → fill inner shadows → strokes → stroke inner shadows"] + v4["one Skia PDF canvas
final z = draw call order"] + v0 --> v1 --> v2 --> v3 --> v4 + end + + tree --> g0 + tree --> v0 + + gS -.uses.-> SHARED + v3 -.uses.-> SHARED +``` + +### Key differences + +| Aspect | Live / GPU | Vector | +|--------|-----------|--------| +| Drawing target | Many GPU surfaces, then composited | One Skia PDF canvas | +| Final z-order | Surface composite order (`draw_shape_surface_stack_into`) | Order of draw calls | +| Drop shadows | Rendered at tree level into a separate surface (`render_element_drop_shadows_and_composite`) | Drawn inline per shape/container (`draw_drop_shadows` / `render_container_drop_shadows`) | +| Images | GPU textures | CPU image copies (`get_cpu_image`) | +| Blur / blurred shadow | GPU filter passes | Rasterised by Skia's PDF backend | +| Perf machinery | tiles, `fast_mode`, `can_render_directly` | none (one-shot export) | + +## Export wiring (single vs multiple) + +The client-side WASM export — rendering in the browser through the vector path +(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single +exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and +only when render-wasm is active and the `:wasm-export` flag is set. + +**Multiple/batch export** (`request-multiple-export`) always runs **server-side** +via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the +server can use its own WASM renderer. So everything documented here (vector PDF, +the fixes, parity) applies to single export only. + +## Parity guards + +Three compile-time guards plus shared code keep the two paths from drifting. +The contract is documented on the `ShapeRenderer` trait +(`render/shape_renderer.rs`). + +1. **Capability guard.** `ShapeRenderer` is the single declaration of per-shape + rendering capabilities (`draw_fills`, `draw_strokes`, `draw_drop_shadows`, + …). A new effect MUST be added as a trait method, not inline in + `render_shape`. Adding a method fails to compile until the vector backend + handles it — so a feature can never be silently missing from PDF. +2. **Type guard.** Every `match` on `shape.shape_type` in `vector.rs` is + exhaustive (no `_ =>`). A new `Type` variant fails to compile until handled. +3. **Order guard.** Leaf content draw order/gating lives in exactly one place: + `vector::render_leaf_content`. It is generic over the + trait so the GPU backend could reuse it verbatim once it implements + `ShapeRenderer`. +4. **Shared primitives.** Prefer reusing the live-render functions over + mirroring them: `draw_stroke_on_rect`, `draw_stroke_on_circle`, + `handle_stroke_caps`, `render_inner_stroke`, `render_overlay_emoji`. + Whatever is still duplicated is the remaining drift surface. + +### Not yet done — full unification + +The end goal is for `render_shape` to also implement `ShapeRenderer` and route +its leaf rendering through `render_leaf_content`, so both paths share order and +gating by construction. This is a large refactor of the live hot path (tiles, +`fast_mode`, surface compositing, tree-level drop shadows) and **should be +gated by pixel parity tests** (a vector-vs-GPU raster diff harness lives on a +separate branch) — do not refactor the live path without that safety net. + +## File map + +| What | Where | +|------|-------| +| Vector entry / PDF | `render/pdf.rs`, `render/vector.rs` | +| Parity trait | `render/shape_renderer.rs` | +| Order seam | `render/vector.rs::render_leaf_content` | +| Live shape render | `render.rs::render_shape` | +| Surface compositing | `render.rs::draw_shape_surface_stack_into` | +| Shared stroke geometry / caps | `render/strokes.rs` | +| Shared text render | `render/text.rs` | diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 83b3ad23f8..ddd30fe356 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -971,6 +971,22 @@ pub fn free_gpu_resources() { get_render_state().free_gpu_resources(); } +#[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!(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)) + }) +} + pub fn main() { // Why an empty main? // Right now with the target `wasm32-unknown-emscripten` it is not possible diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9e867eb2a6..b613ec5586 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -6,13 +6,16 @@ pub mod gpu_state; pub mod grid_layout; mod images; mod options; +pub mod pdf; pub mod rulers; 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/images.rs b/render-wasm/src/render/images.rs index b7a25388dc..b567829ab7 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -213,6 +213,11 @@ impl ImageStore { } } + 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..551a451c92 --- /dev/null +++ b/render-wasm/src/render/pdf.rs @@ -0,0 +1,55 @@ +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 +pub fn render_to_pdf( + shared: &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(); + + 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)); + + let mut on_page = document.begin_page((page_w, page_h), None); + + { + let page_canvas = on_page.canvas(); + page_canvas.scale((scale, scale)); + page_canvas.translate((-bounds.left(), -bounds.top())); + vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?; + } + + let document = on_page.end_page(); + 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..324258acf5 --- /dev/null +++ b/render-wasm/src/render/shape_renderer.rs @@ -0,0 +1,21 @@ +use crate::error::Result; +use crate::shapes::{Fill, Shape, Stroke}; + +/// Capabilities a leaf shape can render, implemented by the canvas-based vector +/// export backend (`vector::VectorRenderer`, used for PDF and future SVG). +/// +/// New per-shape features must be added as a method here (compile error until +/// the backend handles it, so nothing is silently missing from vector export); +/// draw order/gating lives once in `vector::render_leaf_content`. +pub trait ShapeRenderer { + fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()>; + fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()>; + fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()>; + fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()>; + fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()>; + fn draw_text(&mut self, shape: &Shape) -> Result<()>; + fn draw_svg(&mut self, shape: &Shape) -> Result<()>; + /// Returns `true` if a layer was pushed; caller must `restore_blur_layer`. + fn apply_blur_layer(&mut self, shape: &Shape) -> bool; + fn restore_blur_layer(&mut self); +} diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 6f31573288..8221cad4b6 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -12,7 +12,7 @@ use crate::render::filters::compose_filters; use crate::render::{get_dest_rect, get_source_rect}; #[allow(clippy::too_many_arguments)] -fn draw_stroke_on_rect( +pub(super) fn draw_stroke_on_rect( canvas: &skia::Canvas, stroke: &Stroke, rect: &Rect, @@ -97,7 +97,7 @@ fn draw_stroke_on_rect( } #[allow(clippy::too_many_arguments)] -fn draw_stroke_on_circle( +pub(super) fn draw_stroke_on_circle( canvas: &skia::Canvas, stroke: &Stroke, rect: &Rect, @@ -288,7 +288,7 @@ fn handle_stroke_cap( } #[allow(clippy::too_many_arguments)] -fn handle_stroke_caps( +pub(super) fn handle_stroke_caps( path: &skia::Path, stroke: &Stroke, canvas: &skia::Canvas, diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index ff459481f7..4f2d521179 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -1,10 +1,10 @@ -use super::{filters, RenderState, Shape, SurfaceId}; +use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT}; use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, - TextContent, + calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, + StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -150,6 +150,62 @@ pub fn render_with_bounds_outset( stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, +) -> Result<()> { + render_with_bounds_outset_inner( + render_state, + canvas, + shape, + paragraph_builders, + surface_id, + shadow, + blur, + stroke_bounds_outset, + fill_inset, + layer_opacity, + false, + ) +} + +/// Like [`render_with_bounds_outset`] but with emoji bitmap overlay for PDF/vector export. +#[allow(clippy::too_many_arguments)] +pub fn render_with_bounds_outset_overlay_emoji( + canvas: &Canvas, + shape: &Shape, + paragraph_builders: &mut [Vec], + shadow: Option<&Paint>, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + fill_inset: Option, + layer_opacity: Option, +) -> Result<()> { + render_with_bounds_outset_inner( + None, + Some(canvas), + shape, + paragraph_builders, + None, + shadow, + blur, + stroke_bounds_outset, + fill_inset, + layer_opacity, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_with_bounds_outset_inner( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + paragraph_builders: &mut [Vec], + surface_id: Option, + shadow: Option<&Paint>, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + fill_inset: Option, + layer_opacity: Option, + overlay_emoji: bool, ) -> Result<()> { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -179,6 +235,7 @@ pub fn render_with_bounds_outset( Some(&blur_filter_clone), fill_inset, layer_opacity, + false, ); Ok(()) }, @@ -197,6 +254,7 @@ pub fn render_with_bounds_outset( blur, fill_inset, layer_opacity, + false, ); return Ok(()); } @@ -210,6 +268,7 @@ pub fn render_with_bounds_outset( blur, fill_inset, layer_opacity, + overlay_emoji, ); } Ok(()) @@ -241,6 +300,30 @@ pub fn render( ) } +/// Like [`render`] but rasterizes color emoji as bitmap overlays for PDF/vector export. +#[allow(clippy::too_many_arguments)] +pub fn render_overlay_emoji( + canvas: &Canvas, + shape: &Shape, + paragraph_builders: &mut [Vec], + shadow: Option<&Paint>, + blur: Option<&ImageFilter>, + fill_inset: Option, + layer_opacity: Option, +) -> Result<()> { + render_with_bounds_outset_overlay_emoji( + canvas, + shape, + paragraph_builders, + shadow, + blur, + 0.0, + fill_inset, + layer_opacity, + ) +} + +#[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, shape: &Shape, @@ -249,6 +332,7 @@ fn render_text_on_canvas( blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, + overlay_emoji: bool, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -260,7 +344,13 @@ fn render_text_on_canvas( if let Some(shadow_paint) = shadow { let layer_rec = SaveLayerRec::default().paint(shadow_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders, layer_opacity); + draw_text( + canvas, + shape, + paragraph_builders, + layer_opacity, + overlay_emoji, + ); canvas.restore(); } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { @@ -268,13 +358,31 @@ fn render_text_on_canvas( layer_paint.set_image_filter(erode); let layer_rec = SaveLayerRec::default().paint(&layer_paint); canvas.save_layer(&layer_rec); - draw_text(canvas, shape, paragraph_builders, layer_opacity); + draw_text( + canvas, + shape, + paragraph_builders, + layer_opacity, + overlay_emoji, + ); canvas.restore(); } else { - draw_text(canvas, shape, paragraph_builders, layer_opacity); + draw_text( + canvas, + shape, + paragraph_builders, + layer_opacity, + overlay_emoji, + ); } } else { - draw_text(canvas, shape, paragraph_builders, layer_opacity); + draw_text( + canvas, + shape, + paragraph_builders, + layer_opacity, + overlay_emoji, + ); } if blur.is_some() { @@ -289,6 +397,15 @@ fn paint_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], +) { + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); +} + +fn paint_text_with_emoji_overlay( + canvas: &Canvas, + shape: &Shape, + paragraph_builder_groups: &mut [Vec], + overlay_emoji: bool, ) { let text_content = shape.get_text_content(); let layout_info = @@ -296,6 +413,11 @@ fn paint_text( for para in &layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); + + if overlay_emoji { + paint_emoji_overlay(canvas, para); + } + for deco in ¶.decorations { draw_text_decorations( canvas, @@ -309,11 +431,123 @@ fn paint_text( } } +/// Rasterizes color emoji runs as bitmap overlays. Skia's PDF backend can't +/// embed COLR/CBDT color glyphs, so each emoji is drawn to a raster surface and +/// blitted; `paragraph.paint()` already wrote placeholder glyphs (keeps text +/// selectable). +fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) { + let line_metrics = para.paragraph.get_line_metrics(); + + // Rasterize at TARGET_DPI relative to the emoji's on-page size (72 user + // units = 1 inch), capped at MAX_RASTER_PX so a huge font can't allocate + // an unbounded surface. + const TARGET_DPI: f32 = 600.0; + const PDF_POINTS_PER_INCH: f32 = 72.0; + const MAX_RASTER_PX: f32 = 2048.0; + + let ctm = canvas.local_to_device_as_3x3(); + let sx = (ctm.scale_x().powi(2) + ctm.skew_y().powi(2)).sqrt(); + let sy = (ctm.skew_x().powi(2) + ctm.scale_y().powi(2)).sqrt(); + let output_scale = sx.max(sy).max(1.0); + + for line in &line_metrics { + let style_runs = line.get_style_metrics(line.start_index..line.end_index); + + // Build a list of (start, end, is_emoji) for each style run. + let mut run_info: Vec<(usize, usize, bool)> = Vec::new(); + for (i, (start_idx, _style_metric)) in style_runs.iter().enumerate() { + let end_idx = style_runs.get(i + 1).map_or(line.end_index, |next| next.0); + if *start_idx >= end_idx { + continue; + } + + let font = para.paragraph.get_font_at(*start_idx); + let family_name = font.typeface().family_name(); + + let normalized = family_name.to_lowercase().replace(' ', "-"); + let is_emoji = normalized.contains(DEFAULT_EMOJI_FONT); + run_info.push((*start_idx, end_idx, is_emoji)); + } + + // Merge consecutive emoji runs: Skia splits ZWJ sequences (e.g. 👩🏿‍🚀) + // per codepoint, but `get_rects_for_range` needs the full cluster range. + let mut merged_emoji_ranges: Vec<(usize, usize)> = Vec::new(); + for &(start, end, is_emoji) in &run_info { + if is_emoji { + if let Some(last) = merged_emoji_ranges.last_mut() { + if last.1 == start { + // Extend the previous range + last.1 = end; + continue; + } + } + merged_emoji_ranges.push((start, end)); + } + } + + for (range_start, range_end) in &merged_emoji_ranges { + // Get the bounding rects for this (possibly merged) emoji run + let rects = para.paragraph.get_rects_for_range( + *range_start..*range_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + + for text_box in &rects { + let r = &text_box.rect; + let w = r.width(); + let h = r.height(); + if w <= 0.0 || h <= 0.0 { + continue; + } + + // Render at TARGET_DPI relative to the emoji's final on-page + // size, clamped so the surface stays within MAX_RASTER_PX. + let mut raster_scale = output_scale * (TARGET_DPI / PDF_POINTS_PER_INCH); + let max_dim = w.max(h) * raster_scale; + if max_dim > MAX_RASTER_PX { + raster_scale *= MAX_RASTER_PX / max_dim; + } + let raster_w = (w * raster_scale).ceil() as i32; + let raster_h = (h * raster_scale).ceil() as i32; + + let info = skia::ImageInfo::new_n32_premul((raster_w, raster_h), None); + let Some(mut raster) = skia::surfaces::raster(&info, None, None) else { + continue; + }; + + let rc = raster.canvas(); + rc.clear(skia::Color::TRANSPARENT); + rc.scale((raster_scale, raster_scale)); + // Translate so the emoji rect origin maps to (0,0) + rc.translate((-r.left, -r.top)); + para.paragraph.paint(rc, (0.0, 0.0)); + + let image = raster.image_snapshot(); + + // Draw the rasterized emoji onto the PDF canvas at the + // correct position (paragraph offset + emoji rect origin). + let dest = skia::Rect::from_xywh(para.x + r.left, para.y + r.top, w, h); + + let sampling = skia::SamplingOptions::from(skia::CubicResampler::mitchell()); + canvas.draw_image_rect_with_sampling_options( + &image, + None, + dest, + sampling, + &Paint::default(), + ); + } + } + } +} + fn draw_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], layer_opacity: Option, + overlay_emoji: bool, ) { if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); @@ -324,7 +558,7 @@ fn draw_text( canvas.save_layer(&SaveLayerRec::default()); } - paint_text(canvas, shape, paragraph_builder_groups); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders an inner stroke using mask + SrcIn + DstOver layer structure. diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs new file mode 100644 index 0000000000..721a02fbc5 --- /dev/null +++ b/render-wasm/src/render/vector.rs @@ -0,0 +1,957 @@ +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::shape_renderer::ShapeRenderer; +use super::text; +use super::RenderState; +use super::{get_dest_rect, get_source_rect}; + +// --------------------------------------------------------------------------- +// VectorTarget — vector export backend selector +// --------------------------------------------------------------------------- + +/// Vector export backend selector (PDF today; SVG could be added as a variant). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VectorTarget { + Pdf, +} + +// --------------------------------------------------------------------------- +// 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 RenderState, + scale: f32, + _target: VectorTarget, +} + +impl<'a> VectorRenderer<'a> { + pub fn new( + canvas: &'a Canvas, + shared: &'a mut RenderState, + scale: f32, + target: VectorTarget, + ) -> Self { + Self { + canvas, + shared, + 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.shared, 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, self.shared, self.scale, 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 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(); + + for shadow_paint in &drop_shadows { + self.canvas + .save_layer(&skia::canvas::SaveLayerRec::default().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 mask_builders = text_content.paragraph_builder_group_opaque(); + let mut fill_builders = + text_content.paragraph_builder_group_from_text(Some(true)); + text::render_inner_stroke( + None, + Some(self.canvas), + shape, + &mut mask_builders, + stroke_paragraphs, + &mut fill_builders, + 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 mask_builders = text_content.paragraph_builder_group_opaque(); + let mut fill_builders = text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + None, + Some(self.canvas), + shape, + &mut mask_builders, + &mut stroke_paragraphs, + &mut fill_builders, + 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 { + 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( + shared: &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(shared, canvas, element, group.masked, tree, scale, target)?; + } + Type::Frame(_) => { + render_frame(shared, canvas, element, tree, scale, target)?; + } + // Leaf types listed explicitly (no `_`) so a new Type must be handled. + Type::Rect(_) + | Type::Circle + | Type::Path(_) + | Type::Bool(_) + | Type::Text(_) + | Type::SVGRaw(_) => { + render_leaf(shared, canvas, element, scale, target)?; + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +fn render_group( + shared: &mut RenderState, + canvas: &Canvas, + element: &Shape, + masked: bool, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> 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, target, false)?; + + // 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_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + + 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(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + 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().paint(&mask_paint)); + render_tree(shared, canvas, mask_id, tree, scale, target)?; + canvas.restore(); // mask layer + } + + canvas.restore(); // composition layer + } else { + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + } + + if needs_layer { + canvas.restore(); // opacity/blend layer + } + canvas.restore(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Frames +// --------------------------------------------------------------------------- + +fn render_frame( + shared: &mut RenderState, + canvas: &Canvas, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> 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, target, true)?; + + 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 in the frame's own space, then undo the transform so + // children draw at their absolute coords while staying clipped (mirrors the + // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam. + 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, target); + 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(); + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + // Strokes over children (clipped frames), in the frame's space. + 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, target); + 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 RenderState, + canvas: &Canvas, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, + draw_fills: bool, +) -> Result<()> { + 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().paint(&paint)); + + if draw_fills && !element.fills.is_empty() { + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + renderer.draw_fills(element, &element.fills)?; + } + + let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + canvas.restore(); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) +// --------------------------------------------------------------------------- + +fn render_leaf( + shared: &mut RenderState, + canvas: &Canvas, + element: &Shape, + scale: f32, + target: VectorTarget, +) -> 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_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + + // 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)?; + + 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. +fn render_leaf_content(renderer: &mut R, shape: &Shape) -> 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, &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 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) = shared.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); + if let Some(filter) = shape.image_filter(1.) { + paint.set_image_filter(filter); + } + + 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 RenderState, + scale: f32, + shape: &Shape, + stroke: &Stroke, +) -> 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); + } + + draw_stroke_geometry(canvas, scale, shape, stroke, false); + Ok(()) +} + +/// 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, + 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(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + 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 RenderState, + 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(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + + // 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. +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); + } + } +} diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index a1e79a3637..d651e3f457 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1077,6 +1077,15 @@ impl Shape { self.selrect.center() } + // TODO: This can be used in more places + pub fn centered_transform(&self) -> Matrix { + let center = self.center(); + let mut matrix = self.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + matrix + } + pub fn clip(&self) -> bool { self.clip_content } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 3b6835fd30..1995ffe288 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -92,6 +92,10 @@ impl State { get_render_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(get_render_state(), id, &self.shapes, scale) + } + pub fn start_render_loop(&mut self, timestamp: i32) -> Result { let render_state = get_render_state(); // If zoom changed (e.g. interrupted zoom render followed by pan), the