diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs
index 7dadba3d02..32305f9825 100644
--- a/frontend/src/app/main/data/exports/assets.cljs
+++ b/frontend/src/app/main/data/exports/assets.cljs
@@ -166,11 +166,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 07889e05b3..5ed565bbf9 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -2267,6 +2267,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..dffef3183d
--- /dev/null
+++ b/render-wasm/docs/rendering_architecture.md
@@ -0,0 +1,133 @@
+# 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) |
+
+## Transforms (important)
+
+Both paths place a shape at the same world coordinates, but reach it
+differently. For Path/Bool shapes:
+
+- `centered_transform` (**C**) and `to_path_transform` (**P**) are exact
+ inverses: `C · P = I`.
+- `get_skia_path()` already bakes **P** into the geometry (`P · raw`).
+- Live: canvas carries **C**, draws `get_skia_path()` → `C · P · raw = raw`.
+- Vector: same — `render_leaf` concats **C**, `draw_shape_geometry` draws
+ `get_skia_path()` directly. **Do not re-apply `to_path_transform`** — that
+ double-counts and mis-positions/rotates the shape.
+
+## 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 can 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` (a
+`GpuShapeRenderer` delegating to `fills::render` / `strokes::render` /
+`shadows::*`) 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 golden PNG-vs-PDF-raster parity tests** — 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 4ab5677895..d16ae271e6 100644
--- a/render-wasm/src/main.rs
+++ b/render-wasm/src/main.rs
@@ -928,6 +928,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 057287f5e9..7255c903a2 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -6,12 +6,15 @@ pub 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/images.rs b/render-wasm/src/render/images.rs
index b7a25388dc..eeaa9be056 100644
--- a/render-wasm/src/render/images.rs
+++ b/render-wasm/src/render/images.rs
@@ -213,6 +213,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..dcd5c170c0
--- /dev/null
+++ b/render-wasm/src/render/pdf.rs
@@ -0,0 +1,54 @@
+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..3cb3e19469
--- /dev/null
+++ b/render-wasm/src/render/shape_renderer.rs
@@ -0,0 +1,35 @@
+use crate::error::Result;
+use crate::shapes::{Fill, Shape, Stroke};
+
+/// Trait implemented by canvas-based vector export backends (PDF, and
+/// future SVG).
+///
+/// # Parity contract
+///
+/// This trait is the single declaration of the rendering *capabilities* a leaf
+/// shape can have. Two rules keep vector export from drifting away from the
+/// live (GPU) render:
+///
+/// 1. **New capability → new trait method.** Any new per-shape rendering
+/// feature (a new effect, a new fill/stroke mode, etc.) MUST be added as a
+/// method here, not inline in the GPU `render_shape`. Adding a method
+/// produces a compile error in every backend until handled, so the feature
+/// can never be silently missing from vector export.
+/// 2. **Order lives in one place.** The draw order/gating of these methods is
+/// encoded once in `vector::render_leaf_content` (generic over this trait),
+/// so call-order can't diverge between backends that reuse it.
+///
+/// Shape-*type* coverage is enforced separately by exhaustive `match`es (no
+/// `_ =>` arms) in `vector.rs`, so a new `Type` variant also fails to compile
+/// until handled.
+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<()>;
+ 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..1fe6f8d17b 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,138 @@ fn paint_text(
}
}
+/// Detects emoji runs in a laid-out paragraph and rasterizes them as bitmap
+/// overlays on the canvas. Skia's PDF backend cannot render color emoji
+/// glyphs (COLR/CBDT), so we render them to a small raster surface and draw
+/// the resulting image at the correct position. The regular `paragraph.paint()`
+/// already wrote invisible/placeholder glyphs, keeping the text selectable
+/// in the PDF.
+fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) {
+ let line_metrics = para.paragraph.get_line_metrics();
+
+ // The bundled Noto Color Emoji is a vector color font (COLR/CPAL), so emoji
+ // rasterize crisply at any resolution. Skia's PDF backend can't embed those
+ // color tables, so we rasterize each emoji to an image overlay — rendered at
+ // a high effective DPI relative to its final on-page size so it stays sharp
+ // when the PDF is viewed or printed.
+ //
+ // PDF user space is 72 units per inch, so the design→raster scale that yields
+ // TARGET_DPI is output_scale * TARGET_DPI / PDF_POINTS_PER_INCH, where
+ // output_scale is the canvas CTM scale (export scale + any shape transform).
+ // The shape's font size is already folded in via the emoji rect (w/h) below.
+ // Capped by MAX_RASTER_PX so a huge font size 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 into contiguous ranges.
+ // ZWJ emoji sequences (e.g. 👩🏿🚀) may be split into multiple
+ // style runs by Skia — one per codepoint — but the composed glyph
+ // is a single indivisible cluster. Querying `get_rects_for_range`
+ // on a partial cluster returns empty results, so we must use 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 +573,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..c16c192499
--- /dev/null
+++ b/render-wasm/src/render/vector.rs
@@ -0,0 +1,1000 @@
+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
+// ---------------------------------------------------------------------------
+
+/// 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,
+ 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.);
+
+ // Drop shadows for text (fill + per-stroke silhouettes). Each shadow is
+ // a single layer carrying the drop-shadow filter, mirroring GPU
+ // `shadows::render_text_shadows`, so overlapping fill/stroke shadows
+ // don't double-darken.
+ 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 strokes are masked by the glyph fill; mirror GPU
+ // `render_inner_stroke` (outset 0 inside the shadow layer).
+ 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 the stroke to the glyph fill via
+ // `render_inner_stroke` (matches GPU), otherwise it bleeds
+ // outside the glyphs.
+ 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)?;
+ }
+ // All leaf shape types. Explicit (no `_`) so a new Type variant forces
+ // a routing decision here instead of silently falling through to leaf.
+ 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<()> {
+ let matrix = element.centered_transform();
+
+ canvas.save();
+ canvas.concat(&matrix);
+
+ // Drop shadows for the group as a whole (silhouette of the subtree).
+ // Drawn before the opacity/clip layer so the shadow sits below the
+ // content and isn't clipped away.
+ 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);
+ }
+
+ 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(shared, 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(shared, 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(shared, canvas, child_id, tree, scale, target)?;
+ }
+ }
+
+ if needs_layer {
+ canvas.restore(); // opacity/blend layer
+ }
+ canvas.restore(); // transform
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Frames
+// ---------------------------------------------------------------------------
+
+fn render_frame(
+ shared: &mut RenderState,
+ canvas: &Canvas,
+ element: &Shape,
+ tree: ShapesPoolRef,
+ scale: f32,
+ target: VectorTarget,
+) -> Result<()> {
+ let matrix = element.centered_transform();
+
+ canvas.save();
+ canvas.concat(&matrix);
+
+ // Drop shadows for the frame (silhouette of background + subtree). Drawn
+ // before the opacity/clip layer so the shadow sits below the content and
+ // extends outside the (clipped) 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 if clip_content. Outset by ~0.5px (device) like the
+ // GPU clip so edge pixels aliased as "outside" aren't shaved off, leaving
+ // an AA seam between frame content and its border.
+ if element.clip_content {
+ clip_to_frame_content(canvas, element, scale);
+ }
+
+ // Draw the frame's own fills (background) plus any inner shadows on them
+ if !element.fills.is_empty() {
+ let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
+ renderer.draw_fills(element, &element.fills)?;
+ renderer.draw_fill_inner_shadows(element)?;
+ }
+
+ // 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(shared, 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, shared, scale, target);
+ renderer.draw_strokes(element, &visible_strokes)?;
+ }
+
+ if needs_layer {
+ canvas.restore(); // opacity/blend layer
+ }
+ canvas.restore(); // transform
+ Ok(())
+}
+
+/// Renders drop shadows for a container (group/frame) as a silhouette of its
+/// whole subtree, mirroring the GPU path which renders every descendant into
+/// the shadow. For each visible drop shadow the subtree is rendered into a
+/// layer carrying the drop-shadow image filter (which turns the layer's alpha
+/// into a colored, offset, blurred shadow); the real content is drawn
+/// afterwards by the caller, on top. When `draw_fills` is set the container's
+/// own fills (frame background) are included 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);
+ }
+
+ // Use VectorRenderer for all drawing operations via the ShapeRenderer trait.
+ 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
+ };
+
+ // Drop shadows
+ renderer.draw_drop_shadows(element)?;
+
+ // Shape content (fills, inner shadows, strokes) in the canonical order.
+ render_leaf_content(&mut renderer, element)?;
+
+ if blur_layer {
+ renderer.restore_blur_layer();
+ }
+
+ if needs_layer {
+ canvas.restore();
+ }
+
+ canvas.restore();
+ Ok(())
+}
+
+/// Backend-agnostic draw order + gating for a leaf shape's content (fills,
+/// fill inner shadows, strokes, stroke inner shadows).
+///
+/// This is the single source of truth for *content ordering*, expressed purely
+/// in terms of [`ShapeRenderer`] so the GPU backend can share it verbatim once
+/// it implements the trait — making this the one place where order/gating drift
+/// between live and vector render could occur, and a compile error the moment a
+/// new content capability is added to the trait without being ordered here.
+///
+/// Note: drop shadows and layer blur are intentionally NOT here — they wrap the
+/// content and are sequenced differently per backend (GPU composites drop
+/// shadows from a separate surface at the tree level; vector draws them inline).
+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 a leaf content render; listed so a new Type
+ // variant forces a decision here.
+ 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, to avoid
+ // double-drawing (matches GPU `render_stroke_inner_shadows`).
+ if !shape.has_fills() {
+ for stroke in &visible_strokes {
+ renderer.draw_stroke_inner_shadows(shape, stroke)?;
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Private helpers (canvas-only, not part of the trait)
+// ---------------------------------------------------------------------------
+
+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
+ // (matches GPU `draw_image_stroke_in_container`).
+ 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 honoring shape type, kind (inner/outer/center),
+/// and dash/dot style.
+///
+/// - Rect/Frame/Circle reuse the GPU stroke functions (`aligned_rect` +
+/// `clip_op` + `outer_corners`) so dotted/dashed and inner/outer alignment
+/// match the live render exactly.
+/// - Path/Bool use the double-width + clip/clear technique plus explicit caps
+/// for open paths.
+///
+/// Blur/shadow are handled by the per-shape layer in `render_leaf`, so filters
+/// are passed as `None` here. When `opaque` is set the paint color/shader are
+/// overridden to opaque black — used to build an image-stroke silhouette before
+/// an `SrcIn` image draw.
+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 are drawn in draw_text; groups/svg never carry strokes.
+ // Explicit so a new Type variant forces a decision here.
+ Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {}
+ }
+}
+
+/// Draws the shape's stroke geometry honoring the stroke kind (inner/outer/
+/// center) using the same double-width + clip/clear technique as
+/// `draw_single_stroke`. `paint` must already be a stroked paint.
+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);
+ }
+ }
+}
+
+/// Renders a stroke whose fill is an image: draw the stroke silhouette as an
+/// opaque mask inside 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 silhouette of the stroke (kind/dash-aware, including caps); the
+ // SrcIn image draw below replaces the covered pixels with the image.
+ 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 drawn as plain geometry; handled by draw_text / draw_svg /
+ // group traversal. Listed explicitly so a new Type variant forces a
+ // decision here instead of silently no-op'ing in PDF export.
+ 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. Listed explicitly so a new Type
+ // variant forces a decision here instead of defaulting silently.
+ 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 1eb22a3b93..691cfb36f5 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -1077,6 +1077,17 @@ impl Shape {
self.selrect.center()
}
+ /// Returns the shape's transform matrix centered around its selrect center,
+ /// so that rotations and scales happen around the shape's visual center
+ /// rather than the origin.
+ 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 740ef9deef..36627038dc 100644
--- a/render-wasm/src/state.rs
+++ b/render-wasm/src/state.rs
@@ -82,6 +82,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