🐛 Fix using SVG image file as shape fill (#10707)

This commit is contained in:
Elena Torró 2026-07-20 10:41:07 +02:00 committed by GitHub
parent 826072c65d
commit 8d98877ba7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 271 additions and 72 deletions

View File

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

View File

@ -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.
*/

View File

@ -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<u8>),
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<Image>,
},
}
pub struct ImageStore {
@ -143,6 +150,61 @@ fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Ima
Some(surface.image_snapshot())
}
// Size for SVGs without intrinsic dimensions nor a viewBox.
const DEFAULT_SVG_SIZE: f32 = 512.0;
// Parse an SVG and resolve its natural size. Skia codecs don't handle SVG,
// so this is the fallback when `decode_image` fails.
fn parse_svg(raw_data: &[u8]) -> Option<(skia::svg::Dom, Size)> {
// An empty font manager: <text> 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<DirectContext>,
dom: &skia::svg::Dom,
size: Size,
) -> Option<Image> {
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