Compare commits

...
1 Commits
Author SHA1 Message Date
Alejandro Alonso 2e5157e6aa Export image fills to WASM SVG as linked images (#11529)
Emit clipped `<image href>` for fills with a registered media URL instead
of Skia base64 embeds. Register URLs from the frontend and exporter via
store_image_url. Cover rect, path, frame, and text (glyph clip) cases in
SVG export tests.

Closes #11383
2026-09-08 17:30:53 +02:00
22 changed files with 912 additions and 57 deletions

No files matched your search

+17
View File
@@ -25,6 +25,7 @@
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[cuerdas.core :as str]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
@@ -190,6 +191,22 @@
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image-url!
"Registers the public URL an image was loaded from. The SVG export emits
linked `<image href>` from these, and falls back to Skia base64 when missing,
so this should run for every media id the scene references (including
already-cached images).
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
[image-id url]
(when (and (some? url) (not (str/blank? url)))
(let [bytes (js/Buffer.from url "utf-8")
ptr (mem/alloc (.-byteLength bytes))
quart (uuid/get-u32 image-id)]
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
(h/call wasm/internal-module "_store_image_url"
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
+6 -1
View File
@@ -369,13 +369,18 @@
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
and repeated exports of a file reuse them.
Always registers a public media URL for each id so SVG export can emit linked
`<image href>` even when the encoded bytes were already cached."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(doseq [image-id all-ids]
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
+24
View File
@@ -880,6 +880,25 @@
(h/call wasm/internal-module "_store_image")
true)))))
(defn- store-image-url!
"Registers the public URL an image was loaded from so SVG export can emit a
linked `<image href>` instead of a Skia base64 embed."
[image-id url]
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
(let [buffer (uuid/get-u32 image-id)
encoder (js/TextEncoder.)
encoded (.encode encoder url)
size (.-byteLength encoded)
offset (mem/alloc size)
heap (mem/get-heap-u8)]
(.set heap encoded offset)
(h/call wasm/internal-module "_store_image_url"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
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)."
@@ -922,6 +941,7 @@
so Skia rasterizes them."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
(store-image-url! image-id url)
{:key url
:thumbnail? thumbnail?
:callback
@@ -959,6 +979,8 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
;; Always register the URL (SVG export needs it even when bytes are cached).
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))))
@@ -993,6 +1015,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))))
@@ -1021,6 +1044,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
image-ids))))
+5
View File
@@ -12,6 +12,7 @@
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
@@ -27,10 +28,14 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
IMAGE_DIR="$OUT_DIR/images"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
mkdir -p "$IMAGE_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
cp "$IMAGE_SRC" "$IMAGE_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
+15
View File
@@ -82,6 +82,9 @@ pub struct ImageStore {
tick: Cell<u64>,
/// gpu-only
context: Option<Box<DirectContext>>,
/// Source URL registered when the image was fetched (SVG export references
/// this in linked `<image>` elements).
source_urls: HashMap<Uuid, String>,
}
/// Creates a Skia image from an existing WebGL texture.
@@ -227,6 +230,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
source_urls: HashMap::new(),
}
}
@@ -239,6 +243,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: None,
source_urls: HashMap::new(),
}
}
@@ -476,4 +481,14 @@ impl ImageStore {
None
}
}
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
if !url.is_empty() {
self.source_urls.insert(id, url);
}
}
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
self.source_urls.get(id).map(String::as_str)
}
}
+23 -1
View File
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32,
scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
@@ -97,6 +97,28 @@ impl SvgLayerCanvas {
self.out.push_str("</g>");
}
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
pub(super) fn push_raw(&mut self, markup: &str) {
self.flush();
self.out.push_str(markup);
}
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
ctm = ctm * shape.centered_transform();
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
ctm.skew_y(),
ctm.skew_x(),
ctm.scale_y(),
ctm.translate_x(),
ctm.translate_y()
)
}
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
///
/// A mask can be a group too. Since a group has no geometry of its own, we
+147 -2
View File
@@ -5,7 +5,7 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Path, Rect, Segment,
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
Type,
};
@@ -18,6 +18,10 @@ use super::render_tree_to_svg;
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
@@ -28,6 +32,31 @@ pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
pub(super) fn add_image_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
keep_aspect_ratio: bool,
opacity: u8,
) {
add_rect_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Image(ImageFill::new(
image_id,
opacity,
200,
100,
keep_aspect_ratio,
))],
);
}
/// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
@@ -68,15 +97,101 @@ pub(super) fn add_frame(
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Solid(SolidColor(color))],
clip,
);
}
fn add_frame_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fills: Vec<Fill>,
clip: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
shape.set_fills(fills);
shape.set_clip(clip);
}
/// Frame whose background is a linked image fill.
pub(super) fn add_image_frame(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![test_image_fill(image_id)],
clip,
);
}
fn triangle_segments(closed: bool) -> Vec<Segment> {
let mut segments = vec![
Segment::MoveTo((10.0, 90.0)),
Segment::LineTo((50.0, 10.0)),
Segment::LineTo((90.0, 90.0)),
];
if closed {
segments.push(Segment::Close);
}
segments
}
fn add_path_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
segments: Vec<Segment>,
fills: Vec<Fill>,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Path(Path::new(segments)));
shape.set_selrect(l, t, r, b);
shape.set_fills(fills);
}
fn test_image_fill(image_id: Uuid) -> Fill {
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
}
/// Triangle path (open or closed) with a linked image fill.
pub(super) fn add_image_path(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
closed: bool,
image_id: Uuid,
) {
add_path_with_fills(
pool,
id,
parent,
(0.0, 0.0, 100.0, 100.0),
triangle_segments(closed),
vec![test_image_fill(image_id)],
);
}
/// Adds an empty (unmasked) group.
pub(super) fn add_group(
pool: &mut ShapesPool,
@@ -249,9 +364,39 @@ fn stroke_with_style(
stroke
}
/// Text with a linked image fill (register URL via `render_with`).
pub(super) fn add_image_text(
pool: &mut ShapesPool,
id: Uuid,
bounds: (f32, f32, f32, f32),
text: &str,
font_size: f32,
image_id: Uuid,
) {
add_text_with_fills(
pool,
id,
bounds,
text,
font_size,
vec![test_image_fill(image_id)],
);
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
/// before export.
pub(super) fn render_with(
pool: &ShapesPool,
root: Uuid,
setup: impl FnOnce(&mut RenderResources),
) -> String {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
setup(&mut resources);
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect width="200" height="100" fill="url(#g)"/>
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
</svg>

After

Width:  |  Height:  |  Size: 610 B

+3 -7
View File
@@ -5,6 +5,7 @@ use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::emit_fills;
use super::render_tree;
use crate::render::RenderResources;
@@ -29,14 +30,9 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space).
// Frame background (frame space), with linked `<image>` for image fills.
if !element.fills.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
}
// Children (absolute coords).
+109
View File
@@ -0,0 +1,109 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::render::RenderResources;
/// Emits fills bottom -> top for SVG export.
///
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
/// source URL become native linked `<image>` elements (see `store_image_url`);
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
pub(super) fn emit_fills(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
fills: &[Fill],
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
if fills.is_empty() {
return Ok(());
}
// fills[0] is the topmost layer; draw bottom → top.
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_fill(builder, shared, shape, image_fill, tree)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
canvas.restore();
}
}
}
Ok(())
}
/// Emits a linked SVG `<image>` clipped to the shape geometry.
///
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
/// a native `<image href="...">` instead so the export stays linked to the
/// registered media URL (see `store_image_url`).
fn emit_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
tree: ShapesPoolRef,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
} else {
"none"
};
let transform = builder.page_shape_matrix_attr(shape);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()
} else {
format!(r#" opacity="{opacity}""#)
};
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
));
builder.close_group();
}
pub(super) fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+20 -4
View File
@@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{render_leaf_content, VectorRenderer};
use super::shape_renderer::ShapeRenderer;
use super::vector::VectorRenderer;
use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree
@@ -125,6 +126,7 @@ pub(crate) fn render_tree_to_svg(
mod document;
mod frames;
mod groups;
mod images;
mod text;
use document::SvgLayerCanvas;
@@ -133,6 +135,7 @@ use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use images::emit_fills;
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
@@ -172,7 +175,7 @@ fn render_tree(
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
}
}
@@ -180,6 +183,7 @@ fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
@@ -189,14 +193,26 @@ fn render_leaf(
{
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, element)?;
render_text_fill(builder, shared, element)?;
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
render_leaf_content(&mut renderer, element)?;
renderer.draw_fill_inner_shadows(element)?;
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
renderer.draw_strokes(element, &visible_strokes)?;
if !element.has_fills() {
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
}
}
canvas.restore();
}
}
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="100" height="80"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="200" height="120"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -0,0 +1,11 @@
---
source: src/render/svg/tests.rs
assertion_line: 306
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,10 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</svg>
+227 -1
View File
@@ -1,6 +1,6 @@
use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind};
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -962,3 +962,229 @@ fn exports_solid_text_with_font_face() {
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_text() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_text(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
image_id,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"text image fill must emit a linked <image>: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"text image fill must be clipped to glyph silhouette: {svg}"
);
assert!(
svg.contains("<clipPath ") && svg.contains("<text"),
"clipPath must contain text glyphs: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_as_linked_image() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_id,
true,
255,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image"),
"image fill must emit an <image> element: {svg}"
);
assert!(
svg.contains(TEST_IMAGE_URL),
"image href must use the registered URL: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill must slice: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to shape geometry: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
// opacity; shape not at the page origin (page translate in CTM).
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(100.0, 50.0, 508.0, 178.0),
vec![
// fills[0] topmost — solid blue @ 50%
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
// fills[1] underneath — linked image, stretch, ~50% opacity
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
let image_pos = svg.find("<image");
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
assert!(image_pos.is_some(), "missing image fill: {svg}");
assert!(blue_pos.is_some(), "missing top solid fill: {svg}");
assert!(
image_pos.unwrap() < blue_pos.unwrap(),
"image (bottom) must appear before solid (top): {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"none\""),
"mixed image fill should stretch when keep-aspect is off: {svg}"
);
// 128/255 → ~0.50196 as f32 (not a rounded "0.5").
assert!(
svg.contains("opacity=\"0.5019608\""),
"image fill opacity must be emitted: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_closed_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), true, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"closed path must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the path: {svg}"
);
// Clip geometry should be a path (triangle), not a plain rect.
assert!(
svg.contains("<path") || svg.contains(" d=\""),
"closed-path clip must use path geometry: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill on path: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_open_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), false, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"open path must still emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the open path geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_frame() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_frame(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
image_id,
false,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"frame background must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"frame image fill must be clipped to the frame: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"frame image fill should keep aspect: {svg}"
);
// No nested board clip when clip_content is off.
assert!(
!svg.contains("id=\"clip0\""),
"unclipped frame should not wrap children in a board clip: {svg}"
);
insta::assert_snapshot!(svg);
}
+114 -11
View File
@@ -1,19 +1,122 @@
use std::collections::HashSet;
use crate::error::Result;
use crate::shapes::Shape;
use crate::render::text;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::uuid::Uuid;
use super::document::SvgLayerCanvas;
use crate::render::text;
use super::images::{emit_linked_image_element, xml_escape_attr};
use crate::render::RenderResources;
/// Emits a text shape's fill as native `<text>` elements.
/// Emits a text shape's fills for SVG export.
///
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
/// silently drops. Text strokes are handled separately in a later PR.
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
pub(super) fn render_text_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
) -> Result<()> {
let text_content = element.get_text_content();
let text_content = text_content.new_bounds(element.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return Ok(());
}
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_fill(canvas, element);
canvas.restore();
for layer in 0..max_layers {
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
for image_fill in &linked {
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
let mut paragraph_builders = if skip_ids.is_empty() {
text_content.paragraph_builder_group_for_fill_layer(layer)
} else {
text_content
.paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
}
Ok(())
}
fn linked_image_fills_at_layer<'a>(
text_content: &'a crate::shapes::TextContent,
layer: usize,
shared: &RenderResources,
) -> Vec<&'a ImageFill> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for paragraph in text_content.paragraphs() {
for span in paragraph.children() {
if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
out.push(img);
}
}
}
}
out
}
fn layer_has_skia_fills(
text_content: &crate::shapes::TextContent,
layer: usize,
skip_ids: &HashSet<Uuid>,
) -> bool {
text_content.paragraphs().iter().any(|paragraph| {
paragraph
.children()
.iter()
.any(|span| match span.fills_from_bottom(layer) {
Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
Some(_) => true,
None => false,
})
})
}
/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer.
fn emit_text_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
layer: usize,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("txtimgclip");
let text_content = shape.get_text_content().new_bounds(shape.selrect());
let mut paragraph_builders =
text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
let canvas = builder.new_fragment();
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
+7 -16
View File
@@ -530,22 +530,13 @@ fn render_text_on_canvas(
}
}
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
/// `SkSVGDevice` would drop.
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
let text_content = shape.get_text_content();
let text_content = text_content.new_bounds(shape.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return;
}
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
// attributes (merged shaders are dropped). Bottom layer first.
for layer in 0..max_layers {
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
}
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
pub fn paint_text_paragraphs(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
}
/// Lays out and paints paragraph builders without any layer management.
+114 -14
View File
@@ -796,13 +796,13 @@ impl TextContent {
&self,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(use_shadow, false, None, None)
self.paragraph_builders(use_shadow, false, None, None, None, None)
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, true, None, None)
self.paragraph_builders(None, true, None, None, None, None)
}
/// Maximum number of stacked fills across every span in this text block.
@@ -821,7 +821,42 @@ impl TextContent {
&self,
layer_from_bottom: usize,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
}
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
/// this layer is an image in `skip_image_ids` get transparent paint (those
/// fills are re-emitted as linked SVG `<image>` elements).
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
&self,
layer_from_bottom: usize,
skip_image_ids: &HashSet<Uuid>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
Some(layer_from_bottom),
None,
Some(skip_image_ids),
)
}
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
/// the given image — used as an SVG `<clipPath>` for linked image fills.
pub fn paragraph_builder_group_opaque_for_image_layer(
&self,
layer_from_bottom: usize,
image_id: Uuid,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
None,
Some((layer_from_bottom, image_id)),
None,
)
}
fn paragraph_builders(
@@ -830,6 +865,8 @@ impl TextContent {
opaque: bool,
align_override: Option<skia::textlayout::TextAlign>,
fill_layer: Option<usize>,
opaque_image_layer: Option<(usize, Uuid)>,
skip_image_ids: Option<&HashSet<Uuid>>,
) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@@ -843,15 +880,63 @@ impl TextContent {
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
let text_style = span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
);
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
match span.fills_from_bottom(layer) {
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
}
_ => {
paint.set_color(skia::Color::TRANSPARENT);
}
}
style.set_foreground_paint(&paint);
style
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
let skip_span = matches!(
span.fills_from_bottom(layer),
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
);
if skip_span {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
paint.set_color(skia::Color::TRANSPARENT);
style.set_foreground_paint(&paint);
style
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
}
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
};
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
@@ -871,8 +956,14 @@ impl TextContent {
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
let mut measure_builders =
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
let mut measure_builders = self.paragraph_builders(
None,
false,
Some(skia::textlayout::TextAlign::Left),
None,
None,
None,
);
let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@@ -1464,6 +1555,15 @@ pub struct TextSpan {
}
impl TextSpan {
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
if layer < self.fills.len() {
Some(&self.fills[self.fills.len() - 1 - layer])
} else {
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
text: String,
+16
View File
@@ -140,6 +140,22 @@ pub extern "C" fn store_image() -> Result<()> {
Ok(())
}
/// Registers the public URL an image was loaded from for SVG export.
///
/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
#[no_mangle]
#[wasm_error]
pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
let id = uuid_from_u32_quartet(a, b, c, d);
let url_bytes = mem::bytes();
let url = String::from_utf8(url_bytes)
.map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
mem::free_bytes()?;
get_resources().images.set_source_url(id, url);
Ok(())
}
/// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout:
/// - bytes 0-15: shape UUID