From d4d22ff1a4b8b689ed54e9e1274487d0ea00092a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 28 Jul 2026 11:54:22 +0200 Subject: [PATCH] :wrench: Prepare wasm render for headless exporter (#10795) * :wrench: Support background blur on PDF render for strokes and text shapes * :sparkles: Add LRU eviction to the wasm image store * :sparkles: Add RasterFormat to encode png, jpeg and webp from wasm * :bug: Fix client-side wasm export encoding jpeg and webp as png --- frontend/src/app/main/data/exports/wasm.cljs | 5 +- .../main/data/workspace/thumbnails_wasm.cljs | 4 +- frontend/src/app/render_wasm/api.cljs | 8 +- frontend/src/app/render_wasm/serializers.cljs | 10 ++ frontend/src/app/render_wasm/wasm.cljs | 3 +- render-wasm/src/main.rs | 32 +++++- render-wasm/src/render.rs | 17 ++- render-wasm/src/render/images.rs | 104 +++++++++++++++-- render-wasm/src/render/raster.rs | 73 +++++++++++- render-wasm/src/render/vector.rs | 107 ++++++++++++++++-- render-wasm/src/state.rs | 17 ++- 11 files changed, 337 insertions(+), 43 deletions(-) diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index b91f461795..4cbd5285f8 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -13,7 +13,10 @@ (defn export-image-uri [{:keys [type scale object-id]}] - (let [bytes (wasm.api/render-shape-pixels object-id scale) + ;; The export type doubles as the encoder format, so the bytes always match + ;; the mtype we wrap them in. `:svg` never reaches here — it needs vector + ;; markup and `exports.assets/wasm-export-types` keeps it off this path. + (let [bytes (wasm.api/render-shape-pixels object-id scale type) mtype (format->mtype type) blob (wapi/create-blob bytes mtype)] (wapi/create-uri blob))) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 2755c530fd..edef890e1e 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -68,7 +68,9 @@ {sel-w :width sel-h :height} (:selrect frame) max-size (mth/max (or ext-w sel-w) (or ext-h sel-h)) scale (mth/max 1 (/ target-size max-size)) - png-bytes (wasm.api/render-shape-pixels frame-id scale)] + ;; Thumbnails are always PNG: they need the alpha + ;; channel and are consumed as a `image/png` data uri. + png-bytes (wasm.api/render-shape-pixels frame-id scale :png)] (if (or (nil? png-bytes) (zero? (.-length png-bytes))) (do (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0e46a2439f..575a61bb0a 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2629,7 +2629,10 @@ (reset! transition-image* snapshot))))) (defn render-shape-pixels - [shape-id scale] + "Renders a shape subtree to encoded image bytes. `format` is :png, :jpeg or + :webp; jpeg is flattened onto white on the Rust side, since it has no alpha + channel." + [shape-id scale format] (let [buffer (uuid/get-u32 shape-id) offset @@ -2638,7 +2641,8 @@ (aget buffer 1) (aget buffer 2) (aget buffer 3) - scale) + scale + (sr/translate-raster-format format)) heap (mem/get-heap-u8) heapu32 (mem/get-heap-u32) diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 3cc62c3c0c..16aadd7a63 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -138,6 +138,16 @@ default (unchecked-get values "layer-blur")] (d/nilv (unchecked-get values (d/name blur-type)) default))) +(defn translate-raster-format + "Export image format keyword (:png/:jpeg/:webp) -> `RasterFormat` code. + Unlike the other translators this one has NO default: falling back to png + would emit png bytes under a jpeg/webp mtype, which is exactly what the + explicit format threading exists to prevent." + [format] + (let [values (unchecked-get wasm/serializers "raster-format")] + (or (unchecked-get values (d/name format)) + (throw (ex-info "unsupported raster format" {:format format}))))) + (defn translate-layout-flex-dir [flex-dir] (let [values (unchecked-get wasm/serializers "flex-direction")] diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 77d8726347..dee660408a 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -46,7 +46,8 @@ (reset! context-lost? false)) (defonce serializers - #js {:blur-type shared/RawBlurType + #js {:raster-format shared/RasterFormat + :blur-type shared/RawBlurType :blend-mode shared/RawBlendMode :bool-type shared/RawBoolType :font-style shared/RawFontStyle diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 1bf7a130ad..2d007d18e4 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -20,6 +20,7 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; +use crate::render::raster::RasterFormat; use crate::render::{FrameType, RenderFlag}; use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state}; @@ -741,6 +742,19 @@ pub extern "C" fn is_image_cached( Ok(result) } +/// Evicts least-recently-used images until the store retains at most +/// `max_mb` megabytes of image data. Called by the headless exporter between +/// requests — never mid-render, so an image can't disappear under a running +/// export; evicted images are re-provisioned by later requests that need +/// them. Returns the number of evicted images. +#[no_mangle] +#[wasm_error] +pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result { + let max_bytes = (max_mb as usize) * 1024 * 1024; + let evicted = get_resources().images.evict_to_budget(max_bytes); + Ok(evicted as u32) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_shape_svg_raw_content() -> Result<()> { @@ -962,6 +976,9 @@ pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*m }) } +/// Raster image via the GPU surface. Returns `[len][width][height][bytes]` +/// (LE). `format` selects the encoder: 0 = PNG, 1 = JPEG, 2 = WEBP (see +/// `RasterFormat`). #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_pixels( @@ -970,6 +987,7 @@ pub extern "C" fn render_shape_pixels( c: u32, d: u32, scale: f32, + format: u32, ) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); @@ -977,9 +995,11 @@ pub extern "C" fn render_shape_pixels( return Err(Error::CriticalError("Scale is not finite".to_string())); } + let format = RasterFormat::from_u32(format)?; + with_state!(state, { let (data, width, height) = - state.render_shape_pixels(&id, scale, performance::get_time())?; + state.render_shape_pixels(&id, scale, performance::get_time(), format)?; let len = data.len() as u32; let mut buf = Vec::with_capacity(4 + data.len()); @@ -1017,8 +1037,9 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } -/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE), -/// same layout as `render_shape_pixels`. +/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` +/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: +/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_raster( @@ -1027,6 +1048,7 @@ pub extern "C" fn render_shape_raster( c: u32, d: u32, scale: f32, + format: u32, ) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); @@ -1034,8 +1056,10 @@ pub extern "C" fn render_shape_raster( return Err(Error::CriticalError("Scale is not finite".to_string())); } + let format = RasterFormat::from_u32(format)?; + with_state!(state, { - let (data, width, height) = state.render_shape_raster(&id, scale)?; + let (data, width, height) = state.render_shape_raster(&id, scale, format)?; let len = data.len() as u32; let mut buf = Vec::with_capacity(12 + data.len()); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b522d5c4f0..7ff4423f7a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2429,6 +2429,7 @@ impl RenderState { tree: ShapesPoolRef, scale: f32, timestamp: i32, + format: raster::RasterFormat, ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; @@ -2459,7 +2460,7 @@ impl RenderState { self.surfaces .canvas(target_surface) - .clear(skia::Color::TRANSPARENT); + .clear(format.clear_color()); if tree.len() != 0 { let Some(shape) = tree.get(id) else { @@ -2476,6 +2477,14 @@ impl RenderState { self.render_area_with_margins = extrect; self.surfaces.update_render_context(extrect, scale); + // `resize_export_surface` swaps in a brand-new (zeroed, i.e. + // transparent) surface whenever the dimensions change, discarding + // the clear above — so an opaque backdrop has to be laid down again + // here, after the resize. + if let Some(background) = format.opaque_background() { + self.surfaces.canvas(target_surface).clear(background); + } + self.pending_nodes.push(NodeRenderState { id: *id, visited_children: false, @@ -2496,10 +2505,10 @@ impl RenderState { let data = image .encode( Some(&mut get_gpu_state().context), - skia::EncodedImageFormat::PNG, - 100, + format.encoded(), + format.quality(), ) - .expect("PNG encode failed"); + .unwrap_or_else(|| panic!("{format:?} encode failed")); let skia::ISize { width, height } = image.dimensions(); // Restore the workspace render state. diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index a431ae5e4b..79b5e5653a 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -6,6 +6,7 @@ use crate::error::Result; use crate::get_gpu_state; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; use skia_safe::{self as skia, Codec, ISize, Size}; +use std::cell::Cell; use std::collections::HashMap; pub type Image = skia::Image; @@ -66,8 +67,19 @@ enum StoredImage { }, } +struct StoredEntry { + image: StoredImage, + /// Approximate retained cost: encoded byte length (raw/svg) or the + /// decoded RGBA size for images registered from a GL texture. + bytes: usize, + /// LRU tick; `Cell` so read paths can touch it without `&mut self`. + last_used: Cell, +} + pub struct ImageStore { - images: HashMap<(Uuid, bool), StoredImage>, + images: HashMap<(Uuid, bool), StoredEntry>, + total_bytes: usize, + tick: Cell, /// gpu-only context: Option>, } @@ -212,6 +224,8 @@ impl ImageStore { let context = &gpu_state.context; Self { images: HashMap::with_capacity(2048), + total_bytes: 0, + tick: Cell::new(0), context: Some(Box::new(context.clone())), } } @@ -222,10 +236,65 @@ impl ImageStore { pub fn new_without_gpu() -> Self { Self { images: HashMap::with_capacity(16), + total_bytes: 0, + tick: Cell::new(0), context: None, } } + /// Bumps the LRU clock and returns the new tick. + fn next_tick(&self) -> u64 { + let t = self.tick.get() + 1; + self.tick.set(t); + t + } + + fn insert_entry(&mut self, key: (Uuid, bool), image: StoredImage, bytes: usize) { + let last_used = Cell::new(self.next_tick()); + self.total_bytes += bytes; + self.images.insert( + key, + StoredEntry { + image, + bytes, + last_used, + }, + ); + } + + /// Evicts least-recently-used images until the store retains at most + /// `max_bytes`. Meant to be called by the headless exporter *between* + /// requests, so an image can never disappear under a running render; + /// evicted images are simply re-provisioned by a later request that + /// needs them (`is_image_cached` reports them as missing). Returns the + /// number of evicted images. + pub fn evict_to_budget(&mut self, max_bytes: usize) -> usize { + if self.total_bytes <= max_bytes { + return 0; + } + + // Order the keys once instead of rescanning the whole store for the + // minimum on every eviction. + let mut keys: Vec<_> = self + .images + .iter() + .map(|(key, entry)| (entry.last_used.get(), *key)) + .collect(); + keys.sort_unstable_by_key(|(last_used, _)| *last_used); + + let mut evicted = 0; + for (_, key) in keys { + if self.total_bytes <= max_bytes { + break; + } + if let Some(entry) = self.images.remove(&key) { + self.total_bytes -= entry.bytes; + evicted += 1; + } + } + evicted + } + pub fn add( &mut self, id: Uuid, @@ -239,40 +308,43 @@ impl ImageStore { } let raw_data = image_data.to_vec(); + let bytes = raw_data.len(); match self.context.as_mut() { Some(context) => { if let Some(gpu_image) = decode_image(context, &raw_data) { - self.images.insert(key, StoredImage::Gpu(gpu_image)); + self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes); } else if let Some((dom, size)) = parse_svg(&raw_data) { - self.images.insert( + self.insert_entry( key, StoredImage::Svg { dom, size, raster: None, }, + bytes, ); } else { // The lazy re-decode in `get_internal` only retries raster codecs, // so SVGs that fail to parse here stay raw. - self.images.insert(key, StoredImage::Raw(raw_data)); + self.insert_entry(key, StoredImage::Raw(raw_data), bytes); } } // GPU-free: keep the encoded bytes; decoded on the CPU at draw time. // SVGs still get parsed up front since that needs no GPU context. None => { if let Some((dom, size)) = parse_svg(&raw_data) { - self.images.insert( + self.insert_entry( key, StoredImage::Svg { dom, size, raster: None, }, + bytes, ); } else { - self.images.insert(key, StoredImage::Raw(raw_data)); + self.insert_entry(key, StoredImage::Raw(raw_data), bytes); } } } @@ -303,7 +375,8 @@ impl ImageStore { )); }; let image = create_image_from_gl_texture(context, texture_id, width, height)?; - self.images.insert(key, StoredImage::Gpu(image)); + let bytes = (width as usize) * (height as usize) * 4; + self.insert_entry(key, StoredImage::Gpu(image), bytes); Ok(()) } @@ -337,7 +410,9 @@ impl ImageStore { } fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option { - match self.images.get(&(*id, is_thumbnail))? { + let entry = self.images.get(&(*id, is_thumbnail))?; + entry.last_used.set(self.next_tick()); + match &entry.image { StoredImage::Raw(raw_data) => { let data = unsafe { skia::Data::new_bytes(raw_data) }; Image::from_encoded(&data) @@ -361,7 +436,8 @@ impl ImageStore { .images .get(&(*id, false)) .or_else(|| self.images.get(&(*id, true)))?; - match entry { + entry.last_used.set(self.next_tick()); + match &entry.image { StoredImage::Svg { dom, size, .. } => Some((dom, *size)), _ => None, } @@ -369,16 +445,20 @@ impl ImageStore { fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> { let key = (*id, is_thumbnail); + let tick = self.tick.get() + 1; + self.tick.set(tick); // Use entry API to mutate the HashMap in-place if needed if let Some(entry) = self.images.get_mut(&key) { - match entry { + entry.last_used.set(tick); + let image = &mut entry.image; + match image { StoredImage::Gpu(ref img) => Some(img), StoredImage::Raw(raw_data) => { let context = self.context.as_mut()?; let gpu_image = decode_image(context, raw_data)?; - *entry = StoredImage::Gpu(gpu_image); + *image = StoredImage::Gpu(gpu_image); - if let StoredImage::Gpu(ref img) = entry { + if let StoredImage::Gpu(ref img) = image { Some(img) } else { None diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 34692d6ab4..740d20b8d9 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -3,17 +3,78 @@ use skia_safe as skia; use crate::error::{Error, Result}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; +use macros::ToJs; use super::vector; use super::RenderResources; -/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL). -/// Returns `(png_bytes, width_px, height_px)`. +/// Encoded output format for [`render_to_raster`] and `render_shape_pixels`. +/// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side +/// reads them from here instead of hardcoding the codes. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)] +pub enum RasterFormat { + Png = 0, + Jpeg = 1, + Webp = 2, +} + +impl RasterFormat { + /// Maps the wire code sent by the CLJS hosts. + pub fn from_u32(value: u32) -> Result { + match value { + 0 => Ok(Self::Png), + 1 => Ok(Self::Jpeg), + 2 => Ok(Self::Webp), + _ => Err(Error::CriticalError(format!( + "Unknown raster format: {value}" + ))), + } + } + + pub fn encoded(self) -> skia::EncodedImageFormat { + match self { + Self::Png => skia::EncodedImageFormat::PNG, + Self::Jpeg => skia::EncodedImageFormat::JPEG, + Self::Webp => skia::EncodedImageFormat::WEBP, + } + } + + /// Encoder quality. Mirrors the Playwright backend (`bitmap.cljs`): jpeg + /// screenshots default to 95, webp is converted with `-quality 100`. + /// Ignored by the PNG encoder. + pub fn quality(self) -> u32 { + match self { + Self::Jpeg => 95, + _ => 100, + } + } + + /// Opaque backdrop this format requires, or `None` when it keeps alpha. + /// JPEG has no alpha channel, so transparent regions must be flattened onto + /// white — Playwright ignores `omitBackground` for jpeg and does the same. + /// PNG/WEBP keep transparency. + pub fn opaque_background(self) -> Option { + match self { + Self::Jpeg => Some(skia::Color::WHITE), + _ => None, + } + } + + /// Colour to clear a fresh export surface with. + pub fn clear_color(self) -> skia::Color { + self.opaque_background().unwrap_or(skia::Color::TRANSPARENT) + } +} + +/// Renders a shape tree to encoded image bytes on a CPU raster surface (no +/// GPU/WebGL). Returns `(encoded_bytes, width_px, height_px)`. pub fn render_to_raster( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, + format: RasterFormat, ) -> Result<(Vec, i32, i32)> { let Some(shape) = tree.get(id) else { return Ok((Vec::new(), 0, 0)); @@ -40,7 +101,7 @@ pub fn render_to_raster( { let canvas = surface.canvas(); - canvas.clear(skia::Color::TRANSPARENT); + canvas.clear(format.clear_color()); canvas.scale((scale, scale)); canvas.translate((-bounds.left(), -bounds.top())); vector::render_tree(shared, canvas, id, tree, scale, bounds)?; @@ -50,10 +111,10 @@ pub fn render_to_raster( .image_snapshot() .encode( None::<&mut skia::gpu::DirectContext>, - skia::EncodedImageFormat::PNG, - 100, + format.encoded(), + format.quality(), ) - .ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?; + .ok_or_else(|| Error::CriticalError(format!("{format:?} encode failed")))?; Ok((data.as_bytes().to_vec(), width, height)) } diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index cf17a55b8e..23b797023e 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -10,6 +10,7 @@ use crate::uuid::Uuid; use super::shape_renderer::ShapeRenderer; use super::text; use super::RenderResources; +use super::RenderState; use super::{get_dest_rect, get_source_rect}; // --------------------------------------------------------------------------- @@ -438,9 +439,10 @@ fn render_tree_inner( } // Background blur samples already-drawn content behind the shape, so it must - // run before the shape (and its subtree) paints. Text/SVGRaw are excluded, - // matching the GPU path. - if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) { + // run before the shape (and its subtree) paints. SVGRaw is excluded, + // matching the GPU path; text keeps only the glyph-alpha coverage (see the + // text branches below), also matching the GPU path. + if !matches!(element.shape_type, Type::SVGRaw(_)) { if let Some(blur) = element.visible_background_blur() { if blur.value > 0.0 { if opts.embed_bg_blur { @@ -479,8 +481,9 @@ fn render_tree_inner( /// Background blur on a canvas that supports Skia backdrop filters (raster). /// Blurs the current device contents within the shape silhouette and stamps the -/// result back with `Src`. `sigma_radius` is the blur radius already multiplied -/// by the export scale. +/// result back with `Src` — or, for text, keeps it only under the glyph/stroke +/// alpha via a `DstIn` mask (mirrors the GPU path). `sigma_radius` is the blur +/// radius already multiplied by the export scale. fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) { let sigma = radius_to_sigma(sigma_radius); let Some(blur_filter) = @@ -492,7 +495,59 @@ fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: let matrix = shape.centered_transform(); canvas.save(); canvas.concat(&matrix); - clip_to_shape(canvas, shape, true); + + if matches!(shape.shape_type, Type::Text(_)) { + // Text has no closed geometry to clip with: blur the backdrop inside + // the shape rect (outset by the max outward stroke reach so the mask's + // stroke coverage isn't cut off), then keep the blurred result only + // where the opaque glyph/stroke mask is, via DstIn. + let mut clip_rect = shape.selrect; + let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); + if stroke_outset > 0.0 { + clip_rect.outset((stroke_outset, stroke_outset)); + } + canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true); + + // Blur in device space (sigma already includes the export scale); the + // clip survives reset_matrix. Remember the full transform to restore it + // for painting the mask. + let local_to_device = canvas.local_to_device(); + canvas.reset_matrix(); + + // SrcOver composite (NOT Src): Src would clear the backdrop outside + // the glyphs within the clip rect; SrcOver + DstIn mask leaves the + // unmasked backdrop untouched. + let layer_rec = skia::canvas::SaveLayerRec::default() + .backdrop(&blur_filter) + .backdrop_tile_mode(skia::TileMode::Clamp); + canvas.save_layer(&layer_rec); + + canvas.set_matrix(&local_to_device); + + // Keep the blurred backdrop only where the glyphs/strokes are. + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_layer_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + canvas.save_layer(&mask_layer_rec); + + text::paint_text_mask(canvas, shape); + + canvas.restore(); // mask layer + canvas.restore(); // blur layer + canvas.restore(); // clip + transform + return; + } + + // When strokes extend beyond the fill geometry (center/outer), expand the + // clip with the stroke coverage so the backdrop is also blurred under the + // stroke (mirrors the GPU path). + let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open()); + if stroke_outset > 0.0 { + let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset); + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); + } else { + clip_to_shape(canvas, shape, true); + } // Apply the blur in device space (sigma already includes the export scale); // the clip, set with the full transform, survives reset_matrix. canvas.reset_matrix(); @@ -554,6 +609,7 @@ fn render_background_blur_image( // Bake the blur into a raster bitmap. The PDF backend ignores image filters // at draw time (same limitation as backdrop filters), so we must blur on a // raster surface — where filters work — and embed the pre-blurred result. + let is_text = matches!(shape.shape_type, Type::Text(_)); let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale); let blurred = { let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else { @@ -568,13 +624,50 @@ fn render_background_blur_image( paint.set_image_filter(filter); } bc.draw_image(&image, (0.0, 0.0), Some(&paint)); + + if is_text { + // Text has no closed geometry to clip with on the PDF canvas, and + // the PDF backend can't express DstIn either — so bake the + // glyph/stroke alpha mask into the raster bitmap here (where blend + // modes work) and embed the already-masked result. + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + bc.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); + // Same page-space transform used to render the backdrop above. + bc.scale((scale, scale)); + bc.translate((-bounds.left(), -bounds.top())); + bc.concat(&shape.centered_transform()); + text::paint_text_mask(bc, shape); + bc.restore(); + } + blur_surface.image_snapshot() }; let matrix = shape.centered_transform(); canvas.save(); canvas.concat(&matrix); - clip_to_shape(canvas, shape, true); + if is_text { + // Mask is already baked into the bitmap; the clip only bounds it to + // the shape rect (outset by the max outward stroke reach so stroke + // coverage isn't cut off). + let mut clip_rect = shape.selrect; + let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); + if stroke_outset > 0.0 { + clip_rect.outset((stroke_outset, stroke_outset)); + } + canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true); + } else { + // Expand the clip with the stroke coverage when strokes reach beyond + // the fill geometry (mirrors the GPU path). + let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open()); + if stroke_outset > 0.0 { + let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset); + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); + } else { + clip_to_shape(canvas, shape, true); + } + } // Draw the pre-blurred full-page bitmap in device space (1 image px per // device unit) so it aligns with the page regardless of the shape transform. canvas.reset_matrix(); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index f9cd814548..849d35131b 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -10,6 +10,7 @@ pub use ui::RulerState; pub use ui::UIState; use crate::error::{Error, Result}; +use crate::render::raster::RasterFormat; use crate::render::FrameType; use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape}; use crate::uuid::Uuid; @@ -95,18 +96,24 @@ impl State { id: &Uuid, scale: f32, timestamp: i32, + format: RasterFormat, ) -> Result<(Vec, i32, i32)> { - get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp) + get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp, format) } pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result> { crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) } - /// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU - /// raster surface, no GPU/WebGL. - pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec, i32, i32)> { - crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale) + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to + /// `format` on a CPU raster surface, no GPU/WebGL. + pub fn render_shape_raster( + &mut self, + id: &Uuid, + scale: f32, + format: RasterFormat, + ) -> Result<(Vec, i32, i32)> { + crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale, format) } /// Distinct font families used by the (visible) subtree rooted at `id`, in