mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 06:58:12 +00:00
🐛 Fix using SVG image file as shape fill (#10707)
This commit is contained in:
parent
826072c65d
commit
8d98877ba7
@ -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"
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user