diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index cd6cbc09ea..0160a89a4a 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -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 `` 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: diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 6993c53bba..cedd3fd025 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -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 + `` 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) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a9c1718580..cdb7e04ef9 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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 `` 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)))) diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots index 43a23421ca..68140fefdd 100755 --- a/render-wasm/preview-snapshots +++ b/render-wasm/preview-snapshots @@ -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). diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 79b5e5653a..4cdf9396b8 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -82,6 +82,9 @@ pub struct ImageStore { tick: Cell, /// gpu-only context: Option>, + /// Source URL registered when the image was fetched (SVG export references + /// this in linked `` elements). + source_urls: HashMap, } /// 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) + } } diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 083c08f920..f06e086cb4 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -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(""); } + /// 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 `` 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 diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 3ef1feee20..1eb91c95e3 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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 `` 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, + 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 { + 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, + fills: Vec, +) { + 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") diff --git a/render-wasm/src/render/svg/fixtures/test-fill.svg b/render-wasm/src/render/svg/fixtures/test-fill.svg new file mode 100644 index 0000000000..9d7fae920e --- /dev/null +++ b/render-wasm/src/render/svg/fixtures/test-fill.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + IMG + diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index ecb0be1227..0f2d0ddd77 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -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 `` 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). diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs new file mode 100644 index 0000000000..ebaa183a39 --- /dev/null +++ b/render-wasm/src/render/svg/images.rs @@ -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 `` 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 `` clipped to the shape geometry. +/// +/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit +/// a native `` 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 `` + `` 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#""#, + selrect.left(), + selrect.top(), + selrect.width(), + selrect.height(), + )); + builder.close_group(); +} + +pub(super) fn xml_escape_attr(s: &str) -> String { + s.replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 956c6a8e90..8f647ee52c 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -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(); } } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap new file mode 100644 index 0000000000..972225a8f5 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap new file mode 100644 index 0000000000..c33b82fe72 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap new file mode 100644 index 0000000000..470af02231 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap new file mode 100644 index 0000000000..45ab01f3a2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap new file mode 100644 index 0000000000..a982089e5b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 306 +expression: svg +--- + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap new file mode 100644 index 0000000000..2e6d8f7918 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 5e5f24f0df..c66c2d32dc 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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(": {svg}" + ); + assert!( + svg.contains("clip-path=\"url(#"), + "text image fill must be clipped to glyph silhouette: {svg}" + ); + assert!( + svg.contains(" 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("` 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 `` clipped to the glyph silhouette; +/// other fills go through Skia as native ``. 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 = 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, +) -> 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 `` 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(()) } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index ef41565af7..38381a34c3 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -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], +) { + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); } /// Lays out and paints paragraph builders without any layer management. diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f0f2fa9f1e..29792fbd2c 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -796,13 +796,13 @@ impl TextContent { &self, use_shadow: Option, ) -> Vec { - 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 { - 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 { - 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 `` elements). + pub fn paragraph_builder_group_for_fill_layer_skipping_images( + &self, + layer_from_bottom: usize, + skip_image_ids: &HashSet, + ) -> Vec { + 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 `` for linked image fills. + pub fn paragraph_builder_group_opaque_for_image_layer( + &self, + layer_from_bottom: usize, + image_id: Uuid, + ) -> Vec { + 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, fill_layer: Option, + opaque_image_layer: Option<(usize, Uuid)>, + skip_image_ids: Option<&HashSet>, ) -> Vec { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); @@ -843,15 +880,63 @@ impl TextContent { let mut builder = ParagraphBuilder::new(¶graph_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, diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index ce14511220..cbcb25b392 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -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