Use new render on PDF export

This commit is contained in:
Elena Torro 2026-03-23 10:27:32 +01:00
parent f3f697b4a2
commit 3bc8de05fd
14 changed files with 1249 additions and 9 deletions

View File

@ -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

View File

@ -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))

View File

@ -2222,6 +2222,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")

View File

@ -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"] }

View File

@ -937,6 +937,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))
})
}
fn main() {
#[cfg(target_arch = "wasm32")]
init_gl!();

View File

@ -2,16 +2,20 @@ mod debug;
mod fills;
pub mod filters;
mod fonts;
mod gpu_renderer;
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;

View File

@ -0,0 +1,169 @@
use skia_safe::{self as skia};
use super::shape_renderer::ShapeRenderer;
use super::{fills, shadows, strokes, RenderState, SurfaceId};
use crate::error::Result;
use crate::shapes::{Fill, Shape, Stroke, Type};
/// GPU render backend — thin wrapper around the existing surface-based
/// rendering modules (`fills`, `strokes`, `shadows`).
///
/// This struct is not yet used in the hot path (`render_shape`), but it
/// implements [`ShapeRenderer`] so that adding a new trait method produces
/// a compile error here, forcing the GPU side to be updated alongside the
/// PDF backend.
#[allow(dead_code)]
pub struct GpuRenderer<'a> {
pub render_state: &'a mut RenderState,
pub fills_surface_id: SurfaceId,
pub strokes_surface_id: SurfaceId,
pub innershadows_surface_id: SurfaceId,
pub antialias: bool,
pub outset: Option<f32>,
pub fast_mode: bool,
pub scale: f32,
}
impl ShapeRenderer for GpuRenderer<'_> {
fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> {
fills::render(
self.render_state,
shape,
fills,
self.antialias,
self.fills_surface_id,
self.outset,
)
}
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> {
strokes::render(
self.render_state,
shape,
strokes,
Some(self.strokes_surface_id),
self.antialias,
self.outset,
)
}
fn draw_drop_shadows(&mut self, _shape: &Shape) -> Result<()> {
// GPU handles drop shadows at tree traversal level
// (render_shape_tree_partial), not at the leaf level.
Ok(())
}
fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> {
if !self.fast_mode {
shadows::render_fill_inner_shadows(
self.render_state,
shape,
self.antialias,
self.innershadows_surface_id,
);
}
Ok(())
}
fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> {
if !self.fast_mode {
shadows::render_stroke_inner_shadows(
self.render_state,
shape,
stroke,
self.antialias,
self.innershadows_surface_id,
)?;
}
Ok(())
}
fn draw_text(&mut self, shape: &Shape) -> Result<()> {
// GPU text rendering is orchestrated inline in render_shape
// (render.rs). This wrapper delegates to the same text module.
let Type::Text(text_content) = &shape.shape_type else {
return Ok(());
};
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
super::text::render(
Some(self.render_state),
None,
shape,
&mut paragraph_builders,
Some(self.fills_surface_id),
None,
blur_filter.as_ref(),
None,
None,
)?;
Ok(())
}
fn draw_svg(&mut self, shape: &Shape) -> Result<()> {
let Type::SVGRaw(sr) = &shape.shape_type else {
return Ok(());
};
if let Some(svg_transform) = shape.svg_transform() {
self.render_state
.surfaces
.canvas_and_mark_dirty(self.fills_surface_id)
.concat(&svg_transform);
}
if let Some(svg) = shape.svg.as_ref() {
svg.render(
self.render_state
.surfaces
.canvas_and_mark_dirty(self.fills_surface_id),
);
} else {
let font_manager =
skia::FontMgr::from(self.render_state.fonts().font_provider().clone());
if let Ok(dom) = skia::svg::Dom::from_str(&sr.content, font_manager) {
dom.render(
self.render_state
.surfaces
.canvas_and_mark_dirty(self.fills_surface_id),
);
}
}
Ok(())
}
fn apply_blur_layer(&mut self, shape: &Shape) -> bool {
use crate::shapes::{radius_to_sigma, BlurType};
let blur = match shape.blur {
Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b,
_ => return false,
};
let sigma = radius_to_sigma(blur.value * self.scale);
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
let mut paint = skia::Paint::default();
paint.set_image_filter(filter);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.render_state
.surfaces
.canvas(self.fills_surface_id)
.save_layer(&layer_rec);
true
} else {
false
}
}
fn restore_blur_layer(&mut self) {
self.render_state
.surfaces
.canvas(self.fills_surface_id)
.restore();
}
}

View File

@ -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<Image> {
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

View File

@ -0,0 +1,64 @@
use skia_safe as skia;
use crate::error::Result;
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{self, VectorTarget};
use super::RenderState;
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
///
/// This is a dedicated vector-PDF render path that draws directly to a Skia
/// PDF canvas, bypassing the GPU surface system entirely. The result is a
/// true vector PDF — paths, text and fills are represented as PDF drawing
/// operations rather than rasterised bitmaps. Effects that are inherently
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
/// PDF backend, which is the expected behaviour.
pub fn render_to_pdf(
shared: &mut RenderState,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
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<u8> = Vec::new();
// Build PDF metadata
let metadata = skia::pdf::Metadata {
creator: "Penpot".to_string(),
producer: "Penpot (Skia PDF)".to_string(),
..Default::default()
};
let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata));
// begin_page consumes the document and returns an OnPage state
let mut on_page = document.begin_page((page_w, page_h), None);
{
let page_canvas = on_page.canvas();
// Set up coordinate space: scale and translate so the shape's top-left
// maps to the page origin.
page_canvas.scale((scale, scale));
page_canvas.translate((-bounds.left(), -bounds.top()));
// Render the shape tree depth-first onto the PDF canvas.
vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?;
}
// end_page consumes OnPage and returns the open document
let document = on_page.end_page();
// close finalises the PDF and flushes to the writer
document.close();
Ok(pdf_bytes)
}

View File

@ -0,0 +1,37 @@
use crate::error::Result;
use crate::shapes::{Fill, Shape, Stroke};
/// Trait that both GPU and PDF render backends must implement.
///
/// Adding a method here produces a compile error until both backends
/// handle it, ensuring new rendering features are never silently
/// missing from PDF export.
pub trait ShapeRenderer {
/// Draw fills for a shape (solid, gradient, image).
fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()>;
/// Draw strokes for a shape (inner, outer, center).
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()>;
/// Draw drop shadows (offset shadow behind the shape silhouette).
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()>;
/// Draw inner shadows on filled geometry.
fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()>;
/// Draw inner shadows on stroked geometry.
fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()>;
/// Render a text shape (fills, strokes, shadows — full text pipeline).
fn draw_text(&mut self, shape: &Shape) -> Result<()>;
/// Render an SVG raw shape.
fn draw_svg(&mut self, shape: &Shape) -> Result<()>;
/// Apply a layer blur effect. Returns `true` if a save_layer was pushed
/// (caller must call `restore_blur_layer`).
fn apply_blur_layer(&mut self, shape: &Shape) -> bool;
/// Restore the layer pushed by `apply_blur_layer`.
fn restore_blur_layer(&mut self);
}

View File

@ -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_position_data, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
Stroke, StrokeKind, TextContent,
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<f32>,
layer_opacity: Option<f32>,
) -> 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<ParagraphBuilder>],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> 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<ParagraphBuilder>],
surface_id: Option<SurfaceId>,
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
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<ParagraphBuilder>],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> 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<f32>,
layer_opacity: Option<f32>,
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<ParagraphBuilder>],
) {
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<ParagraphBuilder>],
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 &para.decorations {
draw_text_decorations(
canvas,
@ -309,11 +431,113 @@ 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();
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().ceil() as i32;
let h = r.height().ceil() as i32;
if w <= 0 || h <= 0 {
continue;
}
// Rasterize: create a small CPU surface, paint the paragraph
// clipped to this emoji rect, then draw the resulting image
// onto the PDF canvas.
let dpr = 4; // high-DPI factor for quality
let info = skia::ImageInfo::new_n32_premul((w * dpr, h * dpr), None);
let Some(mut raster) = skia::surfaces::raster(&info, None, None) else {
continue;
};
let rc = raster.canvas();
rc.clear(skia::Color::TRANSPARENT);
rc.scale((dpr as f32, dpr as f32));
// 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, r.width(), r.height());
let sampling =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Linear);
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<ParagraphBuilder>],
layer_opacity: Option<f32>,
overlay_emoji: bool,
) {
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
@ -324,7 +548,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.

View File

@ -0,0 +1,667 @@
use skia_safe::{self as skia, Canvas, Paint, RRect};
use crate::error::Result;
use crate::shapes::{
merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type,
};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::get_source_rect;
use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderState;
// ---------------------------------------------------------------------------
// VectorTarget — vector export backend selector
// ---------------------------------------------------------------------------
/// Selects backend-specific behaviour in [`VectorRenderer`].
///
/// Currently only PDF is supported. The enum exists so that adding
/// future vector targets (e.g. SVG) only requires a new variant and
/// target-specific branches in `VectorRenderer`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VectorTarget {
Pdf,
}
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
// ---------------------------------------------------------------------------
/// Canvas-based vector render backend (used by both PDF and SVG export).
/// Draws directly to a Skia canvas (CPU-only, no GPU surfaces).
/// Implements [`ShapeRenderer`] so that adding a new trait method produces
/// a compile error until the vector export path handles it.
pub(super) struct VectorRenderer<'a> {
canvas: &'a Canvas,
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, shape, stroke)?;
}
Ok(())
}
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
for shadow in shape.drop_shadows_visible() {
if let Some(filter) = shadow.get_drop_shadow_filter() {
let mut paint = Paint::default();
paint.set_image_filter(filter);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.canvas.save_layer(&layer_rec);
let mut fill_paint = Paint::default();
fill_paint.set_anti_alias(true);
fill_paint.set_color(skia::Color::BLACK);
draw_shape_geometry(self.canvas, shape, &fill_paint);
self.canvas.restore();
}
}
Ok(())
}
fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> {
if !shape.has_fills() {
return Ok(());
}
for shadow in shape.inner_shadows_visible() {
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
self.canvas
.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
let mut fill_paint = Paint::default();
fill_paint.set_anti_alias(true);
fill_paint.set_color(skia::Color::BLACK);
draw_shape_geometry(self.canvas, shape, &fill_paint);
self.canvas.restore();
}
Ok(())
}
fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> {
let is_open = shape.is_open();
for shadow in shape.inner_shadows_visible() {
if let Some(filter) = shadow.get_inner_shadow_filter() {
let mut paint = stroke.to_stroked_paint(
is_open,
&shape.selrect,
shape.svg_attrs.as_ref(),
true,
);
paint.set_image_filter(filter);
draw_shape_geometry(self.canvas, shape, &paint);
}
}
Ok(())
}
fn draw_text(&mut self, shape: &Shape) -> Result<()> {
let Type::Text(text_content) = &shape.shape_type else {
return Ok(());
};
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
text::render_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,
);
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)?;
}
_ => {
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);
// Layer for opacity / blend mode
let needs_layer = element.needs_layer();
if needs_layer {
let mut paint = Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
}
if masked {
// Masked group: render all children except the last, then apply the
// mask child with DstIn blend.
let children: Vec<Uuid> = 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<Uuid> = 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);
let needs_layer = element.needs_layer();
if needs_layer {
let mut paint = Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
// Frame-level layer blur
if let Some(blur) = element
.blur
.filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0)
{
let sigma = radius_to_sigma(blur.value * scale);
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
}
// Clip to frame bounds if clip_content
if element.clip_content {
clip_to_shape(canvas, element, false);
}
// Draw the frame's own fills (background)
if !element.fills.is_empty() {
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
renderer.draw_fills(element, &element.fills)?;
}
// Render children (forward = back-to-front for painter's algorithm)
let children: Vec<Uuid> = 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(())
}
// ---------------------------------------------------------------------------
// 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-type-specific rendering
match &element.shape_type {
Type::Text(_) => {
renderer.draw_text(element)?;
}
Type::SVGRaw(_) => {
renderer.draw_svg(element)?;
}
_ => {
// Rect, Circle, Path, Bool
renderer.draw_fills(element, &element.fills)?;
// Inner shadows on fills
renderer.draw_fill_inner_shadows(element)?;
// Strokes
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
renderer.draw_strokes(element, &visible_strokes)?;
// Inner shadows on strokes (only if no fills, to avoid double-drawing)
if !element.has_fills() {
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
}
}
}
}
if blur_layer {
renderer.restore_blur_layer();
}
if needs_layer {
canvas.restore();
}
canvas.restore();
Ok(())
}
// ---------------------------------------------------------------------------
// Private helpers (canvas-only, not part of the trait)
// ---------------------------------------------------------------------------
fn draw_image_fill(
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);
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, shape: &Shape, stroke: &Stroke) -> Result<()> {
let is_open = shape.is_open();
let kind = stroke.render_kind(is_open);
let paint = stroke.to_stroked_paint(is_open, &shape.selrect, shape.svg_attrs.as_ref(), true);
match kind {
StrokeKind::Inner => {
// Inner stroke: clip to shape, draw with double width
canvas.save();
clip_to_shape(canvas, shape, true);
draw_shape_geometry(canvas, shape, &paint);
canvas.restore();
}
StrokeKind::Outer => {
// Outer stroke: use save_layer + clip difference to draw outside shape only
canvas.save();
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
draw_shape_geometry(canvas, shape, &paint);
// Clear inside the shape to keep only outer part
let mut clear_paint = Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(true);
let mut fill_paint = clear_paint;
fill_paint.set_style(skia::PaintStyle::Fill);
draw_shape_geometry(canvas, shape, &fill_paint);
canvas.restore(); // layer
canvas.restore();
}
StrokeKind::Center => {
draw_shape_geometry(canvas, shape, &paint);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Draws the shape's geometry (rect/rrect/oval/path) with the given paint.
fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
if let Some(corners) = shape.shape_type.corners() {
let rrect = RRect::new_rect_radii(shape.selrect, &corners);
canvas.draw_rrect(rrect, paint);
} else {
canvas.draw_rect(shape.selrect, paint);
}
}
Type::Circle => {
canvas.draw_oval(shape.selrect, paint);
}
Type::Path(_) | Type::Bool(_) => {
if let Some(path) = shape.get_skia_path() {
if let Some(path_transform) = shape.to_path_transform() {
let transformed = path.make_transform(&path_transform);
canvas.draw_path(&transformed, paint);
} else {
canvas.draw_path(&path, paint);
}
}
}
_ => {}
}
}
/// Clips the canvas to the shape's geometry.
fn clip_to_shape(canvas: &Canvas, shape: &Shape, antialias: bool) {
let container = &shape.selrect;
match &shape.shape_type {
Type::Rect(Rect {
corners: Some(corners),
})
| Type::Frame(Frame {
corners: Some(corners),
..
}) => {
let rrect = RRect::new_rect_radii(*container, corners);
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
}
Type::Rect(_) | Type::Frame(_) => {
canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias);
}
Type::Circle => {
let mut pb = skia::PathBuilder::new();
pb.add_oval(*container, None, None);
canvas.clip_path(&pb.detach(), skia::ClipOp::Intersect, antialias);
}
Type::Path(_) | Type::Bool(_) => {
if let Some(path) = shape.get_skia_path() {
if let Some(path_transform) = shape.to_path_transform() {
canvas.clip_path(
&path.make_transform(&path_transform),
skia::ClipOp::Intersect,
antialias,
);
} else {
canvas.clip_path(&path, skia::ClipOp::Intersect, antialias);
}
}
}
_ => {
canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias);
}
}
}

View File

@ -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
}

View File

@ -81,6 +81,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<Vec<u8>> {
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