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
This commit is contained in:
Alejandro Alonso 2026-09-08 17:30:53 +02:00 committed by GitHub
parent efa2518fe8
commit 2e5157e6aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 912 additions and 57 deletions

View File

@ -25,6 +25,7 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums. ;; Required for side effects: binds the generated enums.
[app.wasm.enums] [app.wasm.enums]
[cuerdas.core :as str]
[promesa.core :as p] [promesa.core :as p]
[shadow.esm :refer [dynamic-import]])) [shadow.esm :refer [dynamic-import]]))
@ -190,6 +191,22 @@
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false))))) 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! (defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into "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: the WASM image store via `_store_image`. Buffer layout matches the Rust reader:

View File

@ -369,13 +369,18 @@
"Fetches and stores every image the scene references (shape, stroke and "Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, 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 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] [scene params]
(let [all-ids (images/scene-image-ids scene) (let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)] new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images" (l/dbg :hint "wasm render: provisioning images"
:total (count all-ids) :total (count all-ids)
:cached (- (count all-ids) (count new-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 (->> new-ids
(map (fn [image-id] (map (fn [image-id]
(->> (fetch-file-media-bytes image-id params) (->> (fetch-file-media-bytes image-id params)

View File

@ -880,6 +880,25 @@
(h/call wasm/internal-module "_store_image") (h/call wasm/internal-module "_store_image")
true))))) 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 (defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to "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)." WASM. This avoids decoding the image twice (once in browser, once in WASM)."
@ -922,6 +941,7 @@
so Skia rasterizes them." so Skia rasterizes them."
[shape-id image-id thumbnail?] [shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)] (let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
(store-image-url! image-id url)
{:key url {:key url
:thumbnail? thumbnail? :thumbnail? thumbnail?
:callback :callback
@ -959,6 +979,8 @@
(aget buffer 2) (aget buffer 2)
(aget buffer 3) (aget buffer 3)
thumbnail?)] 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?) (when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))) (fetch-image shape-id id thumbnail?)))))
@ -993,6 +1015,7 @@
(aget buffer 2) (aget buffer 2)
(aget buffer 3) (aget buffer 3)
thumbnail?)] thumbnail?)]
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?) (when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))) (fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills)))))) (types.fills/get-image-ids fills))))))
@ -1021,6 +1044,7 @@
(aget buffer 2) (aget buffer 2)
(aget buffer 3) (aget buffer 3)
thumbnail?)] thumbnail?)]
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
(when (zero? cached-image?) (when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?)))) (fetch-image shape-id image-id thumbnail?))))
image-ids)))) image-ids))))

View File

@ -12,6 +12,7 @@
# #
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies # Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text. # 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 # 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. # 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" OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf" FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts" 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 "$OUT_DIR"
mkdir -p "$FONT_DIR" mkdir -p "$FONT_DIR"
mkdir -p "$IMAGE_DIR"
cp "$FONT_SRC" "$FONT_DIR/" cp "$FONT_SRC" "$FONT_DIR/"
cp "$IMAGE_SRC" "$IMAGE_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---` # Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes). # line (the YAML front matter insta writes).

View File

@ -82,6 +82,9 @@ pub struct ImageStore {
tick: Cell<u64>, tick: Cell<u64>,
/// gpu-only /// gpu-only
context: Option<Box<DirectContext>>, 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. /// Creates a Skia image from an existing WebGL texture.
@ -227,6 +230,7 @@ impl ImageStore {
total_bytes: 0, total_bytes: 0,
tick: Cell::new(0), tick: Cell::new(0),
context: Some(Box::new(context.clone())), context: Some(Box::new(context.clone())),
source_urls: HashMap::new(),
} }
} }
@ -239,6 +243,7 @@ impl ImageStore {
total_bytes: 0, total_bytes: 0,
tick: Cell::new(0), tick: Cell::new(0),
context: None, context: None,
source_urls: HashMap::new(),
} }
} }
@ -476,4 +481,14 @@ impl ImageStore {
None 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)
}
} }

View File

@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
/// Accumulates the SVG document body while drawing. /// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas { pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32, scale: f32,
page_rect: skia::Rect, page_rect: skia::Rect,
tx: f32, tx: f32,
ty: f32, ty: f32,
@ -97,6 +97,28 @@ impl SvgLayerCanvas {
self.out.push_str("</g>"); 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). /// 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 /// A mask can be a group too. Since a group has no geometry of its own, we

View File

@ -5,7 +5,7 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard; use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources}; use crate::render::{FontStore, RenderResources};
use crate::shapes::{ 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, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
Type, Type,
}; };
@ -18,6 +18,10 @@ use super::render_tree_to_svg;
/// Font URL referenced in exported SVG `@font-face` rules. /// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; 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) { fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string()); 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) 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. /// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect( pub(super) fn add_solid_rect(
pool: &mut ShapesPool, pool: &mut ShapesPool,
@ -68,15 +97,101 @@ pub(super) fn add_frame(
(l, t, r, b): (f32, f32, f32, f32), (l, t, r, b): (f32, f32, f32, f32),
color: skia::Color, color: skia::Color,
clip: bool, 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); let shape = pool.add_shape(id);
shape.set_parent(parent); shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default())); shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b); shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]); shape.set_fills(fills);
shape.set_clip(clip); 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. /// Adds an empty (unmasked) group.
pub(super) fn add_group( pub(super) fn add_group(
pool: &mut ShapesPool, pool: &mut ShapesPool,
@ -249,9 +364,39 @@ fn stroke_with_style(
stroke 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 { 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"); let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts); register_test_font_urls(&mut resources.fonts);
setup(&mut resources);
let _guard = TestRenderResourcesGuard::install(&mut resources); let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export"); let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg") String::from_utf8(bytes).expect("utf8 svg")

View File

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

View File

@ -5,6 +5,7 @@ use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef; use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas}; use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::emit_fills;
use super::render_tree; use super::render_tree;
use crate::render::RenderResources; use crate::render::RenderResources;
@ -29,14 +30,9 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); 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() { if !element.fills.is_empty() {
let canvas = builder.canvas(); emit_fills(builder, shared, element, &element.fills, tree, scale)?;
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
} }
// Children (absolute coords). // Children (absolute coords).

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;")
}

View File

@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef; use crate::state::ShapesPoolRef;
use crate::uuid::Uuid; use crate::uuid::Uuid;
use super::vector::{render_leaf_content, VectorRenderer}; use super::shape_renderer::ShapeRenderer;
use super::vector::VectorRenderer;
use super::RenderResources; use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree /// 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 document;
mod frames; mod frames;
mod groups; mod groups;
mod images;
mod text; mod text;
use document::SvgLayerCanvas; use document::SvgLayerCanvas;
@ -133,6 +135,7 @@ use groups::render_group;
use text::render_text_fill; use text::render_text_fill;
use document::effect_attrs; use document::effect_attrs;
use images::emit_fills;
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body( fn render_body(
@ -172,7 +175,7 @@ fn render_tree(
| Type::Path(_) | Type::Path(_)
| Type::Bool(_) | Type::Bool(_)
| Type::Text(_) | 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, builder: &mut SvgLayerCanvas,
shared: &mut RenderResources, shared: &mut RenderResources,
element: &Shape, element: &Shape,
tree: ShapesPoolRef,
scale: f32, scale: f32,
) -> Result<()> { ) -> Result<()> {
let effects = effect_attrs(element); let effects = effect_attrs(element);
@ -189,14 +193,26 @@ fn render_leaf(
{ {
if matches!(element.shape_type, Type::Text(_)) { if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, element)?; render_text_fill(builder, shared, element)?;
} else { } else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
let matrix = element.centered_transform(); let matrix = element.centered_transform();
let canvas = builder.canvas(); let canvas = builder.canvas();
canvas.save(); canvas.save();
canvas.concat(&matrix); canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false); 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(); canvas.restore();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
use super::fixtures::*; 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::state::ShapesPool;
use crate::uuid::Uuid; use crate::uuid::Uuid;
@ -962,3 +962,229 @@ fn exports_solid_text_with_font_face() {
); );
insta::assert_snapshot!(svg); 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);
}

View File

@ -1,19 +1,122 @@
use std::collections::HashSet;
use crate::error::Result; 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 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` /// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// silently drops. Text strokes are handled separately in a later PR. /// other fills go through Skia as native `<text>`. Strokes are a later PR.
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> { 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 matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save(); for layer in 0..max_layers {
canvas.concat(&matrix); let linked = linked_image_fills_at_layer(&text_content, layer, shared);
text::paint_text_fill(canvas, element); let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
canvas.restore();
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(()) Ok(())
} }

View File

@ -530,22 +530,13 @@ fn render_text_on_canvas(
} }
} }
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that /// Paints pre-built paragraph groups (SVG export path for selective fill layers).
/// `SkSVGDevice` would drop. pub fn paint_text_paragraphs(
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) { canvas: &Canvas,
let text_content = shape.get_text_content(); shape: &Shape,
let text_content = text_content.new_bounds(shape.selrect()); paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
let max_layers = text_content.max_fill_layers(); ) {
if max_layers == 0 { paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
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);
}
} }
/// Lays out and paints paragraph builders without any layer management. /// Lays out and paints paragraph builders without any layer management.

View File

@ -796,13 +796,13 @@ impl TextContent {
&self, &self,
use_shadow: Option<bool>, use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> { ) -> 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). /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering. /// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> { 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. /// Maximum number of stacked fills across every span in this text block.
@ -821,7 +821,42 @@ impl TextContent {
&self, &self,
layer_from_bottom: usize, layer_from_bottom: usize,
) -> Vec<ParagraphBuilderGroup> { ) -> 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( fn paragraph_builders(
@ -830,6 +865,8 @@ impl TextContent {
opaque: bool, opaque: bool,
align_override: Option<skia::textlayout::TextAlign>, align_override: Option<skia::textlayout::TextAlign>,
fill_layer: Option<usize>, fill_layer: Option<usize>,
opaque_image_layer: Option<(usize, Uuid)>,
skip_image_ids: Option<&HashSet<Uuid>>,
) -> Vec<ParagraphBuilderGroup> { ) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection(); let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts(); let fallback_fonts = get_fallback_fonts();
@ -843,15 +880,63 @@ impl TextContent {
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts); let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false; let mut has_text = false;
for span in paragraph.children() { for span in paragraph.children() {
let remove_alpha = let text_style = if let Some((layer, image_id)) = opaque_image_layer {
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); let mut style = span.to_style(
let text_style = span.to_style_with_paint( &self.bounds(),
&self.bounds(), fallback_fonts,
fallback_fonts, false,
remove_alpha, paragraph.line_height(),
paragraph.line_height(), );
fill_layer, 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(); let text: String = span.apply_text_transform();
if !text.is_empty() { if !text.is_empty() {
has_text = true; has_text = true;
@ -871,8 +956,14 @@ impl TextContent {
/// Performs an Auto Width text layout. /// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult { fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
let mut measure_builders = let mut measure_builders = self.paragraph_builders(
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None); None,
false,
Some(skia::textlayout::TextAlign::Left),
None,
None,
None,
);
let normalized_line_height = let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX); calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@ -1464,6 +1555,15 @@ pub struct TextSpan {
} }
impl 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)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
text: String, text: String,

View File

@ -140,6 +140,22 @@ pub extern "C" fn store_image() -> Result<()> {
Ok(()) 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 /// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout: /// Expected memory layout:
/// - bytes 0-15: shape UUID /// - bytes 0-15: shape UUID