diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cf0842c01d..761734502a 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -743,56 +743,87 @@ (aset textures new-id texture) new-id)) -(defn- retrieve-image - [url] - (rx/from - (-> (js/fetch url) - (p/then (fn [^js response] (.blob response))) - (p/then (fn [^js image] (js/createImageBitmap image)))))) +(defn- svg-blob? + [^js blob] + (str/starts-with? (.-type blob) "image/svg")) + +(defn- store-svg-image + "Sends raw SVG bytes to WASM so Skia parses and rasterizes them there. + Browsers reject SVG blobs in `createImageBitmap`, so SVGs skip the + shared-texture path." + [shape-id image-id thumbnail? ^js blob] + (-> (.arrayBuffer blob) + (p/then + (fn [buffer] + (let [image-bytes (js/Uint8Array. buffer) + ;; Header: 16 bytes shape uuid + 16 bytes image uuid + ;; + 4 bytes thumbnail flag, then the raw SVG payload. + offset (mem/alloc (+ 36 (.-byteLength image-bytes))) + heap (mem/get-heap-u8) + dview (mem/get-data-view)] + (-> offset + (mem/write-uuid dview shape-id) + (mem/write-uuid dview image-id) + (mem/write-u32 dview (if thumbnail? 1 0)) + (mem/write-buffer heap image-bytes)) + (h/call wasm/internal-module "_store_image") + true))))) + +(defn- store-image-texture + "Creates a WebGL texture from a decoded image and passes the texture ID to + WASM. This avoids decoding the image twice (once in browser, once in WASM)." + [shape-id image-id thumbnail? img] + (when-let [gl (webgl/get-webgl-context)] + (let [texture (webgl/create-webgl-texture-from-image gl img) + texture-id (get-texture-id-for-gl-object texture) + width (.-width ^js img) + height (.-height ^js img) + ;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail) + ;; + 4 bytes (texture ID) + 8 bytes (dimensions) + total-bytes 48 + offset (mem/alloc->offset-32 total-bytes) + heap32 (mem/get-heap-u32)] + + ;; 1. Set shape id (offset + 0 to offset + 3) + (mem.h32/write-uuid offset heap32 shape-id) + + ;; 2. Set image id (offset + 4 to offset + 7) + (mem.h32/write-uuid (+ offset 4) heap32 image-id) + + ;; 3. Set thumbnail flag as u32 (offset + 8) + (aset heap32 (+ offset 8) (if thumbnail? 1 0)) + + ;; 4. Set texture ID (offset + 9) + (aset heap32 (+ offset 9) texture-id) + + ;; 5. Set width (offset + 10) + (aset heap32 (+ offset 10) width) + + ;; 6. Set height (offset + 11) + (aset heap32 (+ offset 11) height) + + (h/call wasm/internal-module "_store_image_from_texture") + true))) (defn- fetch-image - "Loads an image and creates a WebGL texture from it, passing the texture ID to WASM. - This avoids decoding the image twice (once in browser, once in WASM)." + "Loads an image and hands it to WASM. Raster images are decoded by the + browser and shared as a WebGL texture; SVG images are sent as raw bytes + so Skia rasterizes them." [shape-id image-id thumbnail?] (let [url (cf/resolve-file-media {:id image-id} thumbnail?)] {:key url :thumbnail? thumbnail? :callback (fn [] - (->> (retrieve-image url) - (rx/map - (fn [img] - (when-let [gl (webgl/get-webgl-context)] - (let [texture (webgl/create-webgl-texture-from-image gl img) - texture-id (get-texture-id-for-gl-object texture) - width (.-width ^js img) - height (.-height ^js img) - ;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail) - ;; + 4 bytes (texture ID) + 8 bytes (dimensions) - total-bytes 48 - offset (mem/alloc->offset-32 total-bytes) - heap32 (mem/get-heap-u32)] - - ;; 1. Set shape id (offset + 0 to offset + 3) - (mem.h32/write-uuid offset heap32 shape-id) - - ;; 2. Set image id (offset + 4 to offset + 7) - (mem.h32/write-uuid (+ offset 4) heap32 image-id) - - ;; 3. Set thumbnail flag as u32 (offset + 8) - (aset heap32 (+ offset 8) (if thumbnail? 1 0)) - - ;; 4. Set texture ID (offset + 9) - (aset heap32 (+ offset 9) texture-id) - - ;; 5. Set width (offset + 10) - (aset heap32 (+ offset 10) width) - - ;; 6. Set height (offset + 11) - (aset heap32 (+ offset 11) height) - - (h/call wasm/internal-module "_store_image_from_texture") - true)))) + (->> (rx/from (-> (js/fetch url) + (p/then (fn [^js response] (.blob response))))) + (rx/mapcat + (fn [^js blob] + (rx/from + (if (svg-blob? blob) + (store-svg-image shape-id image-id thumbnail? blob) + (p/then (js/createImageBitmap blob) + (partial store-image-texture shape-id image-id thumbnail?)))))) (rx/catch (fn [cause] (log/error :hint "Could not fetch image" diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 45f8fc3048..a34956be4a 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -5,37 +5,13 @@ use crate::error::Result; use crate::render::get_source_rect; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; -fn draw_image_fill( - render_state: &mut RenderState, +// Set the clipping area to the shape outline within the container bounds +fn clip_to_shape( + canvas: &skia::Canvas, shape: &Shape, - image_fill: &ImageFill, - paint: &Paint, + container: &crate::math::Rect, antialias: bool, - surface_id: SurfaceId, ) { - let Some(image) = render_state.images.get(&image_fill.id()) else { - return; - }; - - let size = image.dimensions(); - let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); - let container = &shape.selrect; - let path_transform = shape.to_path_transform(); - - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; - - let mut image_paint = skia::Paint::default(); - image_paint.set_anti_alias(antialias); - if let Some(filter) = shape.image_filter(1.) { - image_paint.set_image_filter(filter.clone()); - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); - // Save the current canvas state - canvas.save_layer(&layer_rec); - - // Set the clipping rectangle to the container bounds match &shape.shape_type { Type::Rect(Rect { corners: Some(corners), @@ -60,7 +36,7 @@ fn draw_image_fill( } shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { - if let Some(path_transform) = path_transform { + if let Some(path_transform) = shape.to_path_transform() { canvas.clip_path( &path .to_skia_path(shape.svg_attrs.as_ref()) @@ -77,6 +53,49 @@ fn draw_image_fill( Type::Group(_) => unreachable!("A group should not have fills"), Type::Text(_) => unimplemented!("TODO"), } +} + +fn draw_image_fill( + render_state: &mut RenderState, + shape: &Shape, + image_fill: &ImageFill, + paint: &Paint, + antialias: bool, + surface_id: SurfaceId, +) { + if draw_svg_image_fill( + render_state, + shape, + image_fill, + paint, + antialias, + surface_id, + ) { + return; + } + + let Some(image) = render_state.images.get(&image_fill.id()) else { + return; + }; + + let size = image.dimensions(); + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); + let container = &shape.selrect; + + let src_rect = get_source_rect(size, container, image_fill); + let dest_rect = container; + + let mut image_paint = skia::Paint::default(); + image_paint.set_anti_alias(antialias); + if let Some(filter) = shape.image_filter(1.) { + image_paint.set_image_filter(filter.clone()); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); + // Save the current canvas state + canvas.save_layer(&layer_rec); + + clip_to_shape(canvas, shape, container, antialias); // Draw the image with the calculated destination rectangle canvas.draw_image_rect_with_sampling_options( @@ -91,6 +110,63 @@ fn draw_image_fill( canvas.restore(); } +// Draws an SVG image fill from its parsed DOM. The canvas transform carries +// the current zoom, so the SVG rasterizes at display resolution instead of +// being stretched from a fixed-size texture. Returns false when the stored +// image is not an SVG. +fn draw_svg_image_fill( + render_state: &mut RenderState, + shape: &Shape, + image_fill: &ImageFill, + paint: &Paint, + antialias: bool, + surface_id: SurfaceId, +) -> bool { + let Some((dom, size)) = render_state.images.get_svg(&image_fill.id()) else { + return false; + }; + + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); + let container = &shape.selrect; + let size = skia::ISize::new(size.width as i32, size.height as i32); + let src_rect = get_source_rect(size, container, image_fill); + if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { + return true; + } + + let mut image_paint = skia::Paint::default(); + image_paint.set_anti_alias(antialias); + if let Some(filter) = shape.image_filter(1.) { + image_paint.set_image_filter(filter.clone()); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); + canvas.save_layer(&layer_rec); + + clip_to_shape(canvas, shape, container, antialias); + + // Apply the fill paint (opacity/blend) to the whole SVG as one layer. + let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint); + canvas.save_layer(&fill_layer); + + // Map the cropped source rect onto the container: cover semantics when + // keep-aspect-ratio is set, stretch otherwise (same math as the raster + // path, expressed as a canvas transform). + let scale_x = container.width() / src_rect.width(); + let scale_y = container.height() / src_rect.height(); + canvas.translate(( + container.left - src_rect.left * scale_x, + container.top - src_rect.top * scale_y, + )); + canvas.scale((scale_x, scale_y)); + dom.render(canvas); + + canvas.restore(); + canvas.restore(); + + true +} + /** * This SHOULD be the only public function in this module. */ diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index b567829ab7..9473573cfe 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -5,7 +5,7 @@ use crate::uuid::Uuid; use crate::error::Result; use crate::get_gpu_state; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; -use skia_safe::{self as skia, Codec, ISize}; +use skia_safe::{self as skia, Codec, ISize, Size}; use std::collections::HashMap; pub type Image = skia::Image; @@ -57,6 +57,13 @@ pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill enum StoredImage { Raw(Vec), Gpu(Image), + Svg { + dom: skia::svg::Dom, + size: Size, + // Lazy raster for consumers that need a texture (stroke fills, + // exports). The shape fill path draws the DOM directly instead. + raster: Option, + }, } pub struct ImageStore { @@ -143,6 +150,61 @@ fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option Option<(skia::svg::Dom, Size)> { + // An empty font manager: elements inside SVG image fills won't + // resolve typefaces. Wire the render state's font provider here if that + // ever becomes a need. + let font_mgr = skia::FontMgr::new(); + let mut dom = skia::svg::Dom::from_bytes(raw_data, font_mgr).ok()?; + + let mut size = dom.root().intrinsic_size(); + if size.is_empty() { + // SVGs without width/height attributes have no intrinsic size; + // fall back to the viewBox dimensions. + size = dom + .root() + .view_box() + .map(|vb| Size::new(vb.width(), vb.height())) + .unwrap_or_else(|| Size::new(DEFAULT_SVG_SIZE, DEFAULT_SVG_SIZE)); + } + + // Ceil so the size matches the integer dimensions used when rasterizing. + let size = Size::new(size.width.ceil(), size.height.ceil()); + if size.is_empty() { + return None; + } + dom.set_container_size(size); + + Some((dom, size)) +} + +fn rasterize_svg( + context: &mut Box, + dom: &skia::svg::Dom, + size: Size, +) -> Option { + let dimensions = ISize::new(size.width as i32, size.height as i32); + let image_info = skia::ImageInfo::new_n32_premul(dimensions, None); + let mut surface = surfaces::render_target( + context, + Budgeted::Yes, + &image_info, + None, + None, + None, + true, + false, + )?; + + dom.render(surface.canvas()); + Some(surface.image_snapshot()) +} + impl ImageStore { pub fn new() -> Self { let gpu_state = get_gpu_state(); @@ -169,7 +231,18 @@ impl ImageStore { if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) { self.images.insert(key, StoredImage::Gpu(gpu_image)); + } else if let Some((dom, size)) = parse_svg(&raw_data) { + self.images.insert( + key, + StoredImage::Svg { + dom, + size, + raster: None, + }, + ); } 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)); } Ok(()) @@ -218,6 +291,19 @@ impl ImageStore { gpu_image.make_non_texture_image(self.context.as_mut()) } + /// Vector access for SVG images: the fill render path draws the DOM + /// directly so it stays crisp at any zoom level. + pub fn get_svg(&self, id: &Uuid) -> Option<(&skia::svg::Dom, Size)> { + let entry = self + .images + .get(&(*id, false)) + .or_else(|| self.images.get(&(*id, true)))?; + match entry { + StoredImage::Svg { dom, size, .. } => Some((dom, *size)), + _ => None, + } + } + 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 @@ -234,6 +320,12 @@ impl ImageStore { None } } + StoredImage::Svg { dom, size, raster } => { + if raster.is_none() { + *raster = rasterize_svg(&mut self.context, dom, *size); + } + raster.as_ref() + } } } else { None