From e54260a7ac4a694d6f293176960c47c249092748 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 2 Jul 2026 08:16:09 +0200 Subject: [PATCH] :sparkles: Add single-file export to SVG using the WebGL render --- .../src/app/main/data/exports/assets.cljs | 7 +- frontend/src/app/main/data/exports/wasm.cljs | 14 + frontend/src/app/render_wasm/api.cljs | 38 + frontend/src/app/render_wasm/api/fonts.cljs | 29 +- render-wasm/Cargo.lock | 70 + render-wasm/Cargo.toml | 3 + render-wasm/preview-snapshots | 144 ++ render-wasm/src/main.rs | 20 +- render-wasm/src/render.rs | 91 +- render-wasm/src/render/fonts.rs | 173 ++- render-wasm/src/render/images.rs | 37 +- render-wasm/src/render/pdf.rs | 326 ++++- render-wasm/src/render/shadows.rs | 9 +- render-wasm/src/render/svg/document.rs | 406 ++++++ render-wasm/src/render/svg/fixtures.rs | 411 ++++++ render-wasm/src/render/svg/frames.rs | 86 ++ render-wasm/src/render/svg/groups.rs | 56 + render-wasm/src/render/svg/masks.rs | 123 ++ render-wasm/src/render/svg/mod.rs | 281 ++++ render-wasm/src/render/svg/shadows.rs | 307 +++++ render-wasm/src/render/svg/strokes.rs | 243 ++++ render-wasm/src/render/svg/tests/mod.rs | 1194 +++++++++++++++++ ...s__exports_a_frame_with_a_drop_shadow.snap | 12 + ...roup_with_two_rects_and_group_opacity.snap | 9 + ...r__svg__tests__exports_a_masked_group.snap | 10 + ..._a_masked_group_whose_mask_is_a_group.snap | 11 + ...rts_a_path_with_a_solid_center_stroke.snap | 9 + ...orts_a_path_with_a_solid_inner_stroke.snap | 14 + ...orts_a_path_with_a_solid_outer_stroke.snap | 13 + ...s_a_shape_with_a_dotted_center_stroke.snap | 9 + ...hape_with_a_dotted_inner_image_stroke.snap | 13 + ...ts_a_shape_with_a_dotted_inner_stroke.snap | 12 + ...hape_with_a_dotted_outer_image_stroke.snap | 14 + ...ts_a_shape_with_a_dotted_outer_stroke.snap | 13 + ...s__exports_a_shape_with_a_drop_shadow.snap | 8 + ...ts__exports_a_shape_with_a_layer_blur.snap | 8 + ...hape_with_a_solid_center_image_stroke.snap | 11 + ...ts_a_shape_with_a_solid_center_stroke.snap | 9 + ...shape_with_a_solid_inner_image_stroke.snap | 11 + ...rts_a_shape_with_a_solid_inner_stroke.snap | 9 + ...shape_with_a_solid_outer_image_stroke.snap | 11 + ...rts_a_shape_with_a_solid_outer_stroke.snap | 9 + ...s__exports_a_shape_with_an_image_fill.snap | 9 + ..._exports_a_shape_with_an_inner_shadow.snap | 10 + ...der__svg__tests__exports_a_solid_rect.snap | 8 + ...city_and_blend_mode_as_group_wrappers.snap | 8 + ...ports_text_with_a_solid_center_stroke.snap | 15 + ...xports_text_with_a_solid_inner_stroke.snap | 20 + ...xports_text_with_a_solid_outer_stroke.snap | 21 + ...ts__exports_text_with_an_inner_shadow.snap | 15 + ..._shadow_does_not_double_child_shadows.snap | 14 + render-wasm/src/render/svg/text.rs | 136 ++ render-wasm/src/render/text.rs | 78 +- render-wasm/src/render/text_shaping.rs | 34 + render-wasm/src/render/vector.rs | 879 ++++++------ render-wasm/src/shapes.rs | 54 +- render-wasm/src/shapes/fills.rs | 8 + render-wasm/src/shapes/modifiers.rs | 6 +- render-wasm/src/shapes/text.rs | 97 +- render-wasm/src/shapes/text_paths.rs | 4 +- render-wasm/src/state.rs | 10 +- render-wasm/src/utils.rs | 12 - render-wasm/src/wasm/fills/image.rs | 13 + render-wasm/src/wasm/fonts.rs | 27 + render-wasm/src/wasm/text.rs | 15 +- render-wasm/src/wasm/text_editor.rs | 4 +- 66 files changed, 5239 insertions(+), 541 deletions(-) create mode 100755 render-wasm/preview-snapshots create mode 100644 render-wasm/src/render/svg/document.rs create mode 100644 render-wasm/src/render/svg/fixtures.rs create mode 100644 render-wasm/src/render/svg/frames.rs create mode 100644 render-wasm/src/render/svg/groups.rs create mode 100644 render-wasm/src/render/svg/masks.rs create mode 100644 render-wasm/src/render/svg/mod.rs create mode 100644 render-wasm/src/render/svg/shadows.rs create mode 100644 render-wasm/src/render/svg/strokes.rs create mode 100644 render-wasm/src/render/svg/tests/mod.rs create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_frame_with_a_drop_shadow.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group_whose_mask_is_a_group.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_center_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_inner_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_outer_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_center_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_image_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_image_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_drop_shadow.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_layer_blur.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_image_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_image_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_image_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_image_fill.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_inner_shadow.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_center_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_inner_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_outer_stroke.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_an_inner_shadow.snap create mode 100644 render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__frame_drop_shadow_does_not_double_child_shadows.snap create mode 100644 render-wasm/src/render/svg/text.rs create mode 100644 render-wasm/src/render/text_shaping.rs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 24d72e5e92..47e71394a3 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -162,9 +162,6 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) -;; TODO: Remove once we support WASM SVG export -(def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) - (defn- wasm-export-enabled? "WASM export is available: the flag is set AND render-wasm is active for the current file. When render-wasm is inactive its shape tree isn't loaded, so a @@ -176,8 +173,7 @@ (defn- use-wasm-export? "Whether to take the client-side WASM export path for `export`." [state export] - (and (wasm-export-enabled? state) - (contains? wasm-export-types (:type export)))) + (wasm-export-enabled? state)) (defn request-simple-export [{:keys [export]}] @@ -193,6 +189,7 @@ (if (use-wasm-export? state export) (do (case (:type export) + :svg (wasm.exports/export-svg export) :pdf (wasm.exports/export-pdf export) (wasm.exports/export-image export)) (rx/empty)) diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index b91f461795..45f9f5a425 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -41,3 +41,17 @@ (js/queueMicrotask #(wapi/revoke-uri url)) nil)) +(defn export-svg-uri + [{:keys [scale object-id]}] + (let [bytes (wasm.api/render-shape-svg object-id (or scale 1)) + blob (wapi/create-blob bytes "image/svg+xml")] + (wapi/create-uri blob))) + +(defn export-svg + [{:keys [suffix name] :as params}] + (let [url (export-svg-uri params) + filename (str name (or suffix "") ".svg")] + (dom/trigger-download-uri filename "image/svg+xml" url) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) + diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 579eaa0ade..de56ea2fe1 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -733,6 +733,23 @@ (p/then (fn [^js response] (.blob response))) (p/then (fn [^js image] (js/createImageBitmap image)))))) +(defn- store-image-url + [image-id url] + (when (and wasm/context-initialized? (some? url) (not (str/blank? url))) + (let [id-buffer (uuid/get-u32 image-id) + encoder (js/TextEncoder.) + encoded (.encode encoder url) + size (.-byteLength encoded) + ptr (mem/alloc size) + heap (mem/get-heap-u8) + mem (js/Uint8Array. (.-buffer heap) ptr size)] + (.set mem encoded) + (h/call wasm/internal-module "_store_image_url" + (aget id-buffer 0) + (aget id-buffer 1) + (aget id-buffer 2) + (aget id-buffer 3))))) + (defn- fetch-image "Loads an image and creates a WebGL texture from it, passing the texture ID to WASM. This avoids decoding the image twice (once in browser, once in WASM)." @@ -775,6 +792,7 @@ (aset heap32 (+ offset 11) height) (h/call wasm/internal-module "_store_image_from_texture") + (store-image-url image-id url) true)))) (rx/catch (fn [cause] @@ -793,6 +811,7 @@ [shape-id fill thumbnail?] (when-let [image (:fill-image fill)] (let [id (get image :id) + url (cf/resolve-file-media image thumbnail?) buffer (uuid/get-u32 id) cached-image? (h/call wasm/internal-module "_is_image_cached" (aget buffer 0) @@ -800,6 +819,7 @@ (aget buffer 2) (aget buffer 3) thumbnail?)] + (store-image-url id url) (when (zero? cached-image?) (fetch-image shape-id id thumbnail?))))) @@ -2607,6 +2627,24 @@ (mem/free) result)) +(defn render-shape-svg + [shape-id scale] + (let [buffer (uuid/get-u32 shape-id) + offset + (h/call wasm/internal-module "_render_shape_svg" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale) + + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 4) length)] + (mem/free) + result)) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 86946db5ba..e1839aa746 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -130,8 +130,28 @@ (aget shape-id-buffer 3))))) ;; IMPORTANT: Only TTF fonts can be stored. +(defn- store-font-url + [font-data font-url] + (when (and wasm/context-initialized? (some? font-url) (not (str/blank? font-url))) + (let [font-id-buffer (:family-id-buffer font-data) + encoder (js/TextEncoder.) + encoded (.encode encoder font-url) + size (.-byteLength encoded) + ptr (h/call wasm/internal-module "_alloc_bytes" size) + heap (gobj/get ^js wasm/internal-module "HEAPU8") + mem (js/Uint8Array. (.-buffer heap) ptr size)] + (.set mem encoded) + (h/call wasm/internal-module "_store_font_url" + (aget font-id-buffer 0) + (aget font-id-buffer 1) + (aget font-id-buffer 2) + (aget font-id-buffer 3) + (:weight font-data) + (:style font-data)) + true))) + (defn- store-font-buffer - [font-data font-array-buffer emoji? fallback?] + [font-data font-array-buffer font-url emoji? fallback?] (when wasm/context-initialized? (let [font-id-buffer (:family-id-buffer font-data) size (.-byteLength font-array-buffer) @@ -150,6 +170,7 @@ (:style font-data) emoji? fallback?) + (store-font-url font-data font-url) true))) ;; Tracks fonts currently being fetched: {url -> fallback?} @@ -174,7 +195,7 @@ (rx/map (fn [{:keys [body]}] (let [fallback? (get @fetching font-url fallback?)] (swap! fetching dissoc font-url) - (store-font-buffer font-data body emoji? fallback?)))) + (store-font-buffer font-data body font-url emoji? fallback?)))) (rx/catch (fn [cause] (swap! fetching dissoc font-url) (log/error :hint "Could not fetch font" @@ -223,7 +244,9 @@ font-data (assoc font-data :family-id-buffer id-buffer) font-stored? (font-stored? font-data emoji?)] (if font-stored? - (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + (do + (store-font-url font-data uri) + (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))) (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 5d749143fd..ee4141db82 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -99,6 +99,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -114,6 +125,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -130,6 +147,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "filetime" version = "0.2.25" @@ -222,6 +245,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.12.1" @@ -426,6 +461,7 @@ dependencies = [ "gl", "glam", "indexmap", + "insta", "macros", "skia-safe", "thiserror", @@ -519,6 +555,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "skia-bindings" version = "0.93.1" @@ -582,6 +624,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -721,6 +776,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -739,6 +800,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..347c101ae4 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -37,6 +37,9 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } +[dev-dependencies] +insta = "1" + [profile.release] opt-level = 3 lto = "fat" diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots new file mode 100755 index 0000000000..2cfa46bb4f --- /dev/null +++ b/render-wasm/preview-snapshots @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# Visual review helper for the SVG exporter's `insta` snapshots. +# +# This script renders the insta snapshots into an HTML gallery +# so you can judge whether a change is *visually* valid +# before accepting it. +# +# Usage: +# ./preview-snapshots # build the gallery and print its path +# ./preview-snapshots --open # also open it in the default browser +# +# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies +# the bundled font into `target/svg-preview/fonts/` so the gallery renders text. +# +# 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. +# Once a change looks correct, accept it (rename `*.snap.new` -> `*.snap`, or +# `cargo insta accept`) and re-run the tests. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SNAP_DIR="$SCRIPT_DIR/src/render/svg/tests/snapshots" +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" + +mkdir -p "$OUT_DIR" +mkdir -p "$FONT_DIR" +cp "$FONT_SRC" "$FONT_DIR/" + +# Prints the SVG body of a snapshot file: everything after the second `---` +# line (the YAML front matter insta writes). +extract_body() { + awk 'd>=2{print} /^---$/{d++}' "$1" +} + +# Embeds a snapshot's SVG via pointing at a standalone .svg file. +# +# Snapshots must NOT be inlined together into one HTML document: each SVG +# restarts its def ids at `clip0`/`blur0`/..., so inlining several in the same +# document makes `url(#clip0)` collide and resolve to the wrong (or empty) clip +# path. Writing each SVG to its own file isolates ids per document. +# We use (not ) so linked @font-face rules and other external +# resources resolve inside the SVG document. +svg_counter=0 +read_svg_dims() { + local file="$1" + local tag + tag=$(grep -m1 '.*//') + SVG_W=$(echo "$tag" | sed -n 's/.*[[:space:]]width="\([^"]*\)".*/\1/p') + SVG_H=$(echo "$tag" | sed -n 's/.*[[:space:]]height="\([^"]*\)".*/\1/p') +} +emit_svg_box() { + local body_file="$OUT_DIR/svg-$svg_counter.svg" + local name + svg_counter=$((svg_counter + 1)) + name="$(basename "$body_file")" + extract_body "$1" > "$body_file" + read_svg_dims "$body_file" + echo "
" +} + +{ + cat <<'HTML' + + +render-wasm SVG snapshot preview + +

render-wasm SVG snapshot preview

+HTML + + shopt -s nullglob + + for snap in "$SNAP_DIR"/*.snap; do + name="$(basename "$snap" .snap)" + new="$snap.new" + echo "

$name

" + if [ -f "$new" ]; then + echo '

Pending change: review before accepting

' + echo '
' + echo '
accepted (current .snap)
' + emit_svg_box "$snap" + echo '
' + echo '
new (.snap.new)
' + emit_svg_box "$new" + echo '
' + echo '
' + echo '
text diff
'
+            diff -u "$snap" "$new" | sed 's/&/\&/g; s//\>/g' || true
+            echo '
' + else + emit_svg_box "$snap" + fi + echo '
' + done + + # New tests whose snapshot has never been accepted yet. + for new in "$SNAP_DIR"/*.snap.new; do + base="${new%.new}" + [ -f "$base" ] && continue + name="$(basename "$new" .snap.new)" + echo "

$name

" + echo '

new snapshot (no accepted version yet)

' + emit_svg_box "$new" + echo '
' + done +} > "$OUT" + +echo "Wrote $OUT" + +if [ "${1:-}" = "--open" ]; then + xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true +fi diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ffa8d34ca3..1557b22f77 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -936,7 +936,9 @@ pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*m let Some(shape) = state.shapes.get(&id) else { return Err(Error::CriticalError("Shape not found".to_string())); }; - let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, 1.0); + let text_ctx = crate::render::TextShapingCtx::from_session(); + let extrect = + get_render_state().get_cached_extrect(shape, &state.shapes, 1.0, &text_ctx); let mut buf = Vec::with_capacity(16); buf.extend_from_slice(&extrect.x().to_le_bytes()); buf.extend_from_slice(&extrect.y().to_le_bytes()); @@ -1001,6 +1003,22 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state!(state, { + let data = state.render_shape_svg(&id, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + pub fn main() { // Why an empty main? // Right now with the target `wasm32-unknown-emscripten` it is not possible diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index a45de5296f..5c07f6da7a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -7,12 +7,14 @@ pub mod grid_layout; mod images; mod options; pub mod pdf; +pub mod svg; mod shadows; pub mod shape_renderer; mod strokes; mod surfaces; pub mod text; pub mod text_editor; +mod text_shaping; mod ui; mod vector; @@ -38,6 +40,7 @@ use crate::{get_gpu_state, performance}; pub use fonts::*; pub use images::*; +pub use text_shaping::TextShapingCtx; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -1428,12 +1431,13 @@ impl RenderState { }; let text_content: &TextContent = rebound_text_content.as_ref().unwrap_or(stored_text_content); + let text_ctx = TextShapingCtx::from_session(); let count_inner_strokes = shape.count_visible_inner_strokes(); // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(&text_ctx,None); let stroke_kinds: Vec = shape.visible_strokes().rev().map(|s| s.kind).collect(); let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape @@ -1442,6 +1446,7 @@ impl RenderState { .map(|stroke| { text::stroke_paragraph_builder_group_from_text( text_content, + &text_ctx, stroke, &shape.selrect(), None, @@ -1460,6 +1465,7 @@ impl RenderState { None, text_fill_inset, None, + &text_ctx, )?; for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list @@ -1469,7 +1475,7 @@ impl RenderState { { if stroke_kinds[i] == StrokeKind::Inner { let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); + text_content.paragraph_builder_group_from_text(&text_ctx,None); text::render_inner_stroke( Some(self), None, @@ -1480,6 +1486,7 @@ impl RenderState { None, text_stroke_blur_outset, *layer_opacity, + &text_ctx, )?; } else if stroke_kinds[i] == StrokeKind::Outer { text::render_outer_stroke( @@ -1491,6 +1498,7 @@ impl RenderState { None, text_stroke_blur_outset, *layer_opacity, + &text_ctx, )?; } else { text::render_with_bounds_outset( @@ -1504,14 +1512,15 @@ impl RenderState { text_stroke_blur_outset, None, *layer_opacity, + &text_ctx, )?; } } if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); + let mut emoji_builders = text_content.paragraph_builder_group_opaque(&text_ctx); let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); + text_content.paragraph_builder_group_from_text(&text_ctx,None); text::render_emoji_overlay( self, &shape, @@ -1519,6 +1528,7 @@ impl RenderState { &mut deco_builders, strokes_surface_id, None, + &text_ctx, ); } } else { @@ -1531,7 +1541,7 @@ impl RenderState { let inner_shadows = shape.inner_shadow_paints(); let blur_filter = shape.image_filter(1.); let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); + text_content.paragraph_builder_group_from_text(&text_ctx,Some(true)); let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( Vec<_>, Vec<_>, @@ -1541,6 +1551,7 @@ impl RenderState { .map(|stroke| { text::stroke_paragraph_builder_group_from_text( text_content, + &text_ctx, stroke, &shape.selrect(), Some(true), @@ -1561,6 +1572,7 @@ impl RenderState { blur_filter.as_ref(), None, None, + &text_ctx, )?; } } else { @@ -1574,6 +1586,7 @@ impl RenderState { &blur_filter, &stroke_kinds, text_content, + &text_ctx, )?; } } else { @@ -1590,6 +1603,7 @@ impl RenderState { blur_filter.as_ref(), None, None, + &text_ctx, )?; } } @@ -1605,6 +1619,7 @@ impl RenderState { blur_filter.as_ref(), text_fill_inset, None, + &text_ctx, )?; // 3. Stroke drop shadows @@ -1618,6 +1633,7 @@ impl RenderState { &blur_filter, &stroke_kinds, text_content, + &text_ctx, )?; // 4. Stroke fills @@ -1628,7 +1644,7 @@ impl RenderState { { if stroke_kinds[i] == StrokeKind::Inner { let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); + text_content.paragraph_builder_group_from_text(&text_ctx,None); text::render_inner_stroke( Some(self), None, @@ -1639,6 +1655,7 @@ impl RenderState { blur_filter.as_ref(), text_stroke_blur_outset, *layer_opacity, + &text_ctx, )?; } else if stroke_kinds[i] == StrokeKind::Outer { text::render_outer_stroke( @@ -1650,6 +1667,7 @@ impl RenderState { blur_filter.as_ref(), text_stroke_blur_outset, *layer_opacity, + &text_ctx, )?; } else { text::render_with_bounds_outset( @@ -1663,22 +1681,24 @@ impl RenderState { text_stroke_blur_outset, None, *layer_opacity, + &text_ctx, )?; } } if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); + let mut emoji_builders = text_content.paragraph_builder_group_opaque(&text_ctx); let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); + text_content.paragraph_builder_group_from_text(&text_ctx,None); text::render_emoji_overlay( self, &shape, &mut emoji_builders, &mut deco_builders, - strokes_surface_id, - blur_filter.as_ref(), - ); + strokes_surface_id, + blur_filter.as_ref(), + &text_ctx, + ); } // 5. Stroke inner shadows @@ -1692,6 +1712,7 @@ impl RenderState { &blur_filter, &stroke_kinds, text_content, + &text_ctx, )?; // 6. Fill Inner shadows @@ -1707,6 +1728,7 @@ impl RenderState { blur_filter.as_ref(), None, None, + &text_ctx, )?; } } @@ -1863,6 +1885,7 @@ impl RenderState { None => Vec::new(), }; + let text_ctx = TextShapingCtx::from_session(); for shape_id in root_ids { let Some(shape) = tree.get(&shape_id) else { continue; @@ -1871,7 +1894,7 @@ impl RenderState { continue; } - let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); + let doc_bounds = self.get_cached_extrect(shape, tree, 1.0, &text_ctx); if !doc_bounds.intersects(viewport) { continue; } @@ -2248,11 +2271,12 @@ impl RenderState { }; let mut acc: Option = None; + let text_ctx = TextShapingCtx::from_session(); for id in ids.iter() { let Some(shape) = tree.get(id) else { continue; }; - let r = self.get_cached_extrect(shape, tree, 1.0); + let r = self.get_cached_extrect(shape, tree, 1.0, &text_ctx); if r.is_empty() { continue; } @@ -2383,7 +2407,8 @@ impl RenderState { // FIXME return Ok((Vec::new(), 0, 0)); }; - let mut extrect = shape.extrect(tree, scale); + let text_ctx = TextShapingCtx::from_session(); + let mut extrect = shape.extrect(tree, scale, &text_ctx); self.export_context = Some((extrect, scale)); let margins = self.surfaces.margins; extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); @@ -2744,7 +2769,8 @@ impl RenderState { pub fn get_shape_extrect_bounds(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Rect { let scale = self.get_scale(); - let rect = self.get_cached_extrect(shape, tree, scale); + let text_ctx = TextShapingCtx::from_session(); + let rect = self.get_cached_extrect(shape, tree, scale, &text_ctx); self.get_rect_bounds(rect) } @@ -3016,7 +3042,8 @@ impl RenderState { node_render_state: &NodeRenderState, target_surface: SurfaceId, ) -> Result<()> { - let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); + let text_ctx = TextShapingCtx::from_session(); + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale, &text_ctx)); let inherited_layer_blur = match element.shape_type { Type::Frame(_) | Type::Group(_) => element.blur, _ => None, @@ -3060,7 +3087,7 @@ impl RenderState { if !matches!(shadow_shape.shape_type, Type::Text(_)) { self.render_drop_black_shadow( shadow_shape, - &shadow_shape.extrect(tree, scale), + &shadow_shape.extrect(tree, scale, &text_ctx), shadow, nested_clip_bounds, scale, @@ -3156,12 +3183,13 @@ impl RenderState { // `modifier_ids` is pre-computed once here and reused throughout the loop to avoid // repeated allocations (formerly O(N_shapes) HashMap builds) per node. let modifier_ids = tree.modifier_ids(); + let text_ctx = TextShapingCtx::from_session(); let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() { let mut acc: Option = None; for id in modifier_ids.iter() { // Current (post-modifier) bounds if let Some(s) = tree.get(id) { - let r = self.get_cached_extrect(s, tree, 1.0); + let r = self.get_cached_extrect(s, tree, 1.0, &text_ctx); acc = Some(match acc { None => r, Some(mut prev) => { @@ -3175,7 +3203,7 @@ impl RenderState { // shape at its original position are considered "unsafe" even after the shape // has moved away (e.g. dragging a child out of a clipped frame). if let Some(raw) = tree.get_raw(id) { - let r0 = self.get_cached_extrect(raw, tree, 1.0); + let r0 = self.get_cached_extrect(raw, tree, 1.0, &text_ctx); acc = Some(match acc { None => r0, Some(mut prev) => { @@ -3205,6 +3233,7 @@ impl RenderState { continue; }; let scale = self.get_scale(); + let text_ctx = TextShapingCtx::from_session(); let mut extrect: Option = None; // If the shape is not in the tile set, then we add them. @@ -3267,14 +3296,14 @@ impl RenderState { let is_visible = export || mask || if is_container || has_effects { - let element_extrect = - extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale)); + let element_extrect = extrect + .get_or_insert_with(|| transformed_element.extrect(tree, scale, &text_ctx)); element_extrect.intersects(self.render_area_with_margins) - && !transformed_element.visually_insignificant(scale, tree) + && !transformed_element.visually_insignificant(scale, tree, &text_ctx) } else { let selrect = transformed_element.selrect(); selrect.intersects(self.render_area_with_margins) - && !transformed_element.visually_insignificant(scale, tree) + && !transformed_element.visually_insignificant(scale, tree, &text_ctx) }; if self.options.is_debug_visible() { @@ -3708,7 +3737,8 @@ impl RenderState { */ pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { let scale = self.get_scale(); - let extrect = self.get_cached_extrect(shape, tree, scale); + let text_ctx = TextShapingCtx::from_session(); + let extrect = self.get_cached_extrect(shape, tree, scale, &text_ctx); let tile_size = tiles::get_tile_size(scale); let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size); let interest_rect = &self.tile_viewbox.interest_rect; @@ -3769,7 +3799,8 @@ impl RenderState { // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. if tree.get_modifier(&shape.id).is_some() { if let Some(raw_shape) = tree.get_raw(&shape.id) { - let old_extrect = raw_shape.extrect(tree, 1.0); + let text_ctx = TextShapingCtx::from_session(); + let old_extrect = raw_shape.extrect(tree, 1.0, &text_ctx); self.surfaces .atlas .clear_doc_rect_in_atlas_clipped(old_extrect); @@ -4044,8 +4075,14 @@ impl RenderState { self.touched_ids.clear(); } - pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { - shape.extrect(tree, scale) + pub fn get_cached_extrect( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + scale: f32, + ctx: &TextShapingCtx, + ) -> Rect { + shape.extrect(tree, scale, ctx) } pub fn set_view(&mut self, zoom: f32, x: f32, y: f32) { diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..332b182546 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -1,5 +1,5 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::error::{Error, Result}; use crate::shapes::{FontFamily, FontStyle}; @@ -26,6 +26,9 @@ pub struct FontStore { debug_font: Font, ui_font: Font, fallback_fonts: HashSet, + /// Source URL registered when the font was fetched (SVG export references + /// this in `@font-face` rules). + source_urls: HashMap, } impl FontStore { @@ -55,6 +58,7 @@ impl FontStore { debug_font, ui_font, fallback_fonts: HashSet::new(), + source_urls: HashMap::new(), }) } @@ -131,6 +135,173 @@ impl FontStore { pub fn get_emoji_font(&self, _size: f32) -> Option { None } + + pub fn set_source_url(&mut self, alias: &str, url: String) { + if !url.is_empty() { + self.source_urls.insert(alias.to_string(), url); + } + } + + pub fn source_url(&self, alias: &str) -> Option<&str> { + self.source_urls.get(alias).map(String::as_str) + } + +/// Builds `@font-face` CSS rules for the given registered aliases. +/// +/// Each rule references the source URL registered for the alias at load time. +/// Aliases without a registered URL are omitted. +pub fn font_face_css_for_aliases(&self, aliases: &HashSet) -> String { + let mut seen: HashSet = HashSet::new(); + let mut css = String::new(); + + for alias in aliases { + let Some(typeface) = self + .font_provider + .match_family_style(alias, skia::FontStyle::default()) + else { + continue; + }; + + let family = typeface.family_name(); + let style = typeface.font_style(); + + // Skia's SVG backend derives `` font descriptors from the + // typeface's own `SkFontStyle` using a quirky bucketed table (see + // `skia_svg_font_weight`). We must mirror it exactly here so each + // `@font-face` pairs with the `` elements that reference it; + // otherwise, when several weights of the same family coexist, the + // browser cannot match the weight and silently falls back to 400. + let weight = skia_svg_font_weight(*style.weight()); + let slant = match style.slant() { + skia::font_style::Slant::Italic => "italic", + skia::font_style::Slant::Oblique => "oblique", + _ => "normal", + }; + let stretch = skia_svg_font_stretch(*style.width()); + + let dedup_key = format!("{family}|{weight}|{slant}|{stretch:?}"); + if !seen.insert(dedup_key) { + continue; + } + + let stretch_decl = stretch + .map(|s| format!("font-stretch:{s};")) + .unwrap_or_default(); + + let Some(url) = self.source_url(alias) else { + continue; + }; + let src = font_face_src_from_url(url); + + css.push_str(&format!( + "@font-face{{font-family:\"{family}\";font-style:{slant};font-weight:{weight};{stretch_decl}src:{src};}}", + )); + } + + css + } +} + +fn font_face_src_from_url(url: &str) -> String { + let format = font_format_from_url(url); + format!( + "url(\"{}\") format(\"{format}\")", + css_escape_url(url) + ) +} + +fn font_format_from_url(url: &str) -> &'static str { + let path = url + .split('#') + .next() + .unwrap_or(url) + .split('?') + .next() + .unwrap_or(url); + if path.ends_with(".woff2") { + "woff2" + } else if path.ends_with(".woff") { + "woff" + } else if path.ends_with(".otf") { + "opentype" + } else { + "truetype" + } +} + +fn css_escape_url(url: &str) -> String { + url.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle}; + use crate::uuid::Uuid; + + #[test] + fn font_face_css_uses_registered_url() { + let mut store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let alias = family.alias(); + store.set_source_url( + &alias, + "https://example.com/fonts/source.ttf".to_string(), + ); + + let mut aliases = HashSet::new(); + aliases.insert(alias); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.contains("url(\"https://example.com/fonts/source.ttf\")")); + assert!(css.contains("format(\"truetype\")")); + assert!(!css.contains("base64,")); + } + + #[test] + fn font_face_css_skips_without_registered_url() { + let store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let mut aliases = HashSet::new(); + aliases.insert(family.alias()); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.is_empty()); + } +} + +/// Reproduces the `font-weight` string that `SkSVGDevice::addTextAttributes` +/// writes on `` elements for a given typeface weight. +fn skia_svg_font_weight(weight: i32) -> &'static str { + // Skia's table is ["100","200","300","normal","400","500","600","bold", + // "800","900"]; we substitute "400" for the omitted-normal bucket so the + // descriptor still resolves to weight 400. + const WEIGHTS: [&str; 10] = [ + "100", "200", "300", "400", "400", "500", "600", "bold", "800", "900", + ]; + let index = ((weight.clamp(100, 900) - 50) / 100) as usize; + WEIGHTS[index] +} + +/// Reproduces the `font-stretch` value `SkSVGDevice` writes for a typeface +/// width, returning `None` for the normal width (which Skia omits). +fn skia_svg_font_stretch(width: i32) -> Option<&'static str> { + const STRETCHES: [&str; 9] = [ + "ultra-condensed", + "extra-condensed", + "condensed", + "semi-condensed", + "normal", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded", + ]; + let index = width - 1; + if index == 4 { + return None; + } + STRETCHES.get(usize::try_from(index).ok()?).copied() } fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider { diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index b567829ab7..06f3717f31 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -62,6 +62,31 @@ enum StoredImage { pub struct ImageStore { images: HashMap<(Uuid, bool), StoredImage>, context: Box, + /// Source URL registered when the image was fetched (referenced in SVG export). + source_urls: HashMap, +} + +/// Abstracts [`ImageStore`] so the exporters don't depend on a live +/// GPU context. Wasm uses `ImageStore`; headless tests inject a CPU-only +/// fake. +pub trait ImageProvider { + fn get_cpu_image(&mut self, id: &Uuid) -> Option; + + fn source_url(&self, id: &Uuid) -> Option<&str> { + let _ = id; + None + } +} + +impl ImageProvider for ImageStore { + fn get_cpu_image(&mut self, id: &Uuid) -> Option { + let gpu_image = self.get(id)?.clone(); + gpu_image.make_non_texture_image(self.context.as_mut()) + } + + fn source_url(&self, id: &Uuid) -> Option<&str> { + self.source_urls.get(id).map(String::as_str) + } } /// Creates a Skia image from an existing WebGL texture. @@ -150,6 +175,13 @@ impl ImageStore { Self { images: HashMap::with_capacity(2048), context: Box::new(context.clone()), + source_urls: HashMap::new(), + } + } + + pub fn set_source_url(&mut self, id: Uuid, url: String) { + if !url.is_empty() { + self.source_urls.insert(id, url); } } @@ -213,11 +245,6 @@ impl ImageStore { } } - pub fn get_cpu_image(&mut self, id: &Uuid) -> Option { - let gpu_image = self.get(id)?.clone(); - gpu_image.make_non_texture_image(self.context.as_mut()) - } - fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> { let key = (*id, is_thumbnail); // Use entry API to mutate the HashMap in-place if needed diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index 551a451c92..e9e0b33286 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -1,17 +1,24 @@ -use skia_safe as skia; +use skia_safe::{self as skia, Canvas, Paint}; use crate::error::Result; +use crate::render::TextShapingCtx; +use crate::shapes::{radius_to_sigma, BlurType, Shape, Stroke, Type}; use crate::state::ShapesPoolRef; +use crate::utils::Browser; use crate::uuid::Uuid; -use super::vector::{self, VectorTarget}; +use super::shape_renderer::ShapeRenderer; +use super::vector::{ + children_paint_order, clip_to_frame_content, render_leaf_content, ExportState, VectorRenderer, + VectorTarget, +}; use super::RenderState; /// Renders a shape tree to a PDF document and returns the raw PDF bytes. /// /// This is a dedicated vector-PDF render path that draws directly to a Skia /// PDF canvas, bypassing the GPU surface system entirely. The result is a -/// true vector PDF — paths, text and fills are represented as PDF drawing +/// true vector PDF: paths, text and fills are represented as PDF drawing /// operations rather than rasterised bitmaps. Effects that are inherently /// pixel-based (blur, shadows with blur) are rasterised internally by Skia's /// PDF backend @@ -24,7 +31,8 @@ pub fn render_to_pdf( let shape = tree .get(id) .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - let bounds = shape.extrect(tree, scale); + let text_ctx = TextShapingCtx::new(shared.fonts(), Browser::Chrome); + let bounds = shape.extrect(tree, scale, &text_ctx); let page_w = bounds.width() * scale; let page_h = bounds.height() * scale; @@ -42,10 +50,15 @@ pub fn render_to_pdf( let mut on_page = document.begin_page((page_w, page_h), None); { + let mut ctx = ExportState { + fonts: &shared.fonts, + images: Some(&mut shared.images as &mut dyn super::ImageProvider), + sampling_options: shared.sampling_options, + }; let page_canvas = on_page.canvas(); page_canvas.scale((scale, scale)); page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?; + render_tree(&mut ctx, page_canvas, id, tree, scale, VectorTarget::Pdf)?; } let document = on_page.end_page(); @@ -53,3 +66,306 @@ pub fn render_to_pdf( Ok(pdf_bytes) } + +// --------------------------------------------------------------------------- +// Tree traversal +// --------------------------------------------------------------------------- + +/// Depth-first render of the shape tree rooted at `id` onto a Skia canvas. +/// +/// This is the PDF export path: it draws straight to the (PDF) canvas and uses +/// `save_layer` for composite effects, which the PDF backend supports. SVG has +/// its own compositor (see the `svg` module) because `SkSVGDevice` drops +/// `save_layer` content. +fn render_tree( + shared: &mut ExportState, + canvas: &Canvas, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + let Some(element) = tree.get(id) else { + return Ok(()); + }; + + if element.hidden { + return Ok(()); + } + + match &element.shape_type { + Type::Group(group) => { + render_group(shared, canvas, element, group.masked, tree, scale, target)?; + } + Type::Frame(_) => { + render_frame(shared, canvas, element, tree, scale, target)?; + } + // Leaf types listed explicitly (no `_`) so a new Type must be handled. + Type::Rect(_) + | Type::Circle + | Type::Path(_) + | Type::Bool(_) + | Type::Text(_) + | Type::SVGRaw(_) => { + render_leaf(shared, canvas, element, scale, target)?; + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +fn render_group( + shared: &mut ExportState, + canvas: &Canvas, + element: &Shape, + masked: bool, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + // A group has no geometry of its own and does NOT propagate a transform to + // its children: child shapes are stored in absolute coordinates and each + // applies its own `centered_transform`. (Concatenating the group transform + // here would double-apply it to children (visible on rotated/nested groups.) + canvas.save(); + + // Group drop shadow: subtree silhouette, below the opacity/mask layer. + render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?; + + // Layer for opacity / blend mode / group layer blur (and masking). + let needs_layer = element.needs_layer(); + if needs_layer { + let mut paint = Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + + if let Some(blur) = element + .blur + .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) + { + let sigma = radius_to_sigma(blur.value * scale); + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); + } + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + let children = children_paint_order(tree, element); + + if masked { + // Mirror the GPU mask: render the content children into a composition + // layer, then re-draw the mask silhouette (the group's first child) with + // `DstIn` so it clips everything to its alpha. This preserves soft/alpha + // masks exactly (unlike a geometric clip). The SVG backend can't keep + // `save_layer` content, so its compositor uses a `` instead. + canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&Paint::default())); + + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + if let Some(mask_id) = element.mask_id() { + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); + render_tree(shared, canvas, mask_id, tree, scale, target)?; + canvas.restore(); // mask layer + } + + canvas.restore(); // composition layer + } else { + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + } + + if needs_layer { + canvas.restore(); // opacity/blend layer + } + canvas.restore(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Frames +// --------------------------------------------------------------------------- + +fn render_frame( + shared: &mut ExportState, + canvas: &Canvas, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, +) -> Result<()> { + // A frame's own geometry (background, clip, strokes) is placed by its + // `centered_transform`, but, like groups, it does NOT propagate that + // transform to its children, which are stored in absolute coordinates. So + // the transform is applied only around the frame's own draws; children are + // rendered untransformed. + let matrix = element.centered_transform(); + + canvas.save(); + + // Frame drop shadow: background + subtree silhouette, below the clip layer + // so it extends outside the frame bounds. + render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?; + + let needs_layer = element.needs_layer(); + + if needs_layer { + let mut paint = Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + + // Frame-level layer blur + if let Some(blur) = element + .blur + .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) + { + let sigma = radius_to_sigma(blur.value * scale); + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); + } + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + // Clip to frame bounds in the frame's own space, then undo the transform so + // children draw at their absolute coords while staying clipped (mirrors the + // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam. + if element.clip_content { + canvas.concat(&matrix); + clip_to_frame_content(canvas, element, scale); + if let Some(inverse) = matrix.invert() { + canvas.concat(&inverse); + } + } + + // Frame's own fills (background) + inner shadows, in the frame's space. + if !element.fills.is_empty() { + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + renderer.draw_fills(element, &element.fills)?; + renderer.draw_fill_inner_shadows(element)?; + canvas.restore(); + } + + // Children (absolute coords, no frame transform). + let children = children_paint_order(tree, element); + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + // Strokes over children (clipped frames), in the frame's space. + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + renderer.draw_strokes(element, &visible_strokes)?; + canvas.restore(); + } + + if needs_layer { + canvas.restore(); // opacity/blend layer + } + canvas.restore(); + Ok(()) +} + +/// Drop shadows for a container: render the subtree into a drop-shadow filter +/// layer (its alpha becomes the shadow). `draw_fills` includes the frame +/// background in the silhouette. +fn render_container_drop_shadows( + shared: &mut ExportState, + canvas: &Canvas, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + target: VectorTarget, + draw_fills: bool, +) -> Result<()> { + for shadow in element.drop_shadows_visible() { + let Some(filter) = shadow.get_drop_shadow_filter() else { + continue; + }; + let mut paint = Paint::default(); + paint.set_image_filter(filter); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + + if draw_fills && !element.fills.is_empty() { + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + renderer.draw_fills(element, &element.fills)?; + } + + let children = children_paint_order(tree, element); + for child_id in &children { + render_tree(shared, canvas, child_id, tree, scale, target)?; + } + + canvas.restore(); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) +// --------------------------------------------------------------------------- + +fn render_leaf( + shared: &mut ExportState, + canvas: &Canvas, + element: &Shape, + scale: f32, + target: VectorTarget, +) -> Result<()> { + let needs_layer = element.needs_layer(); + + let matrix = element.centered_transform(); + + canvas.save(); + canvas.concat(&matrix); + + // Layer for opacity/blend + if needs_layer { + let mut paint = Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + canvas.save_layer(&layer_rec); + } + + let mut renderer = VectorRenderer::new(canvas, shared, scale, target); + + // Layer blur (non-text shapes) + let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { + renderer.apply_blur_layer(element) + } else { + false + }; + + renderer.draw_drop_shadows(element)?; + render_leaf_content(&mut renderer, element)?; + + if blur_layer { + renderer.restore_blur_layer(); + } + + if needs_layer { + canvas.restore(); + } + + canvas.restore(); + Ok(()) +} diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c3de172ad8..194b1fd71e 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -1,4 +1,5 @@ use super::{RenderState, SurfaceId}; +use crate::render::TextShapingCtx; use crate::render::strokes; use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; use skia_safe::{canvas::SaveLayerRec, Paint, Path}; @@ -138,6 +139,7 @@ pub fn render_text_shadows( blur_filter: &Option, stroke_kinds: &[StrokeKind], text_content: &TextContent, + ctx: &TextShapingCtx, ) -> Result<()> { if stroke_paragraphs_group.is_empty() { return Ok(()); @@ -161,11 +163,13 @@ pub fn render_text_shadows( blur_filter.as_ref(), None, None, + ctx, )?; for (i, stroke_paragraphs) in stroke_paragraphs_group.iter_mut().enumerate() { if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true)); + let mut fill_builders = + text_content.paragraph_builder_group_from_text(ctx, Some(true)); text::render_inner_stroke( None, Some(canvas), @@ -176,6 +180,7 @@ pub fn render_text_shadows( blur_filter.as_ref(), 0.0, None, + ctx, )?; } else if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Outer { text::render_outer_stroke( @@ -187,6 +192,7 @@ pub fn render_text_shadows( blur_filter.as_ref(), 0.0, None, + ctx, )?; } else { text::render( @@ -199,6 +205,7 @@ pub fn render_text_shadows( blur_filter.as_ref(), None, None, + ctx, )?; } } diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs new file mode 100644 index 0000000000..1933b7e30c --- /dev/null +++ b/render-wasm/src/render/svg/document.rs @@ -0,0 +1,406 @@ +use std::collections::HashMap; + +use skia_safe::{self as skia, Paint}; + +use crate::error::Result; +use crate::shapes::{radius_to_sigma, BlurType, Shape, Stroke, Type}; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget}; + +// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a +// `save_layer`, so composite effects (container/leaf opacity, blend mode, layer +// blur, masks) rendered using `save_layer` vanish in SVG. +// +// Instead of one canvas, the SVG path composes the document itself: leaf +// content is drawn into short-lived `skia::svg::Canvas` *fragments* (real +// ``/``/… vector markup), and composite effects become native SVG +// `` wrappers (`opacity`, `mix-blend-mode`, `filter="feGaussianBlur"`, +// `clip-path`). This keeps the output fully vectorial and matches the GPU +// result without rasterizing. + +/// Accumulates the SVG document body while drawing. +pub(crate) struct SvgLayerCanvas { + pub(super) scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, + pub(super) out: String, + pub(super) defs: String, + pending: Option, + next_id: usize, + frag_no: usize, + def_cache: HashMap, +} + +impl SvgLayerCanvas { + pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self { + Self { + scale, + page_rect, + tx, + ty, + out: String::new(), + defs: String::new(), + pending: None, + next_id: 0, + frag_no: 0, + def_cache: std::collections::HashMap::new(), + } + } + + pub(super) fn unique(&mut self, prefix: &str) -> String { + let id = format!("{prefix}{}", self.next_id); + self.next_id += 1; + id + } + + /// Page transform applied to every Skia SVG fragment (`scale` then `translate`). + pub(crate) fn page_transform(&self) -> skia::Matrix { + let mut matrix = skia::Matrix::new_identity(); + matrix.set_scale((self.scale, self.scale), None); + matrix.post_translate((self.tx, self.ty)); + matrix + } + + /// Transform for leaf content in export space: page offset/scale plus the + /// shape's `centered_transform`, matching `render_leaf`'s Skia canvas setup. + pub(crate) fn leaf_transform(&self, shape: &Shape) -> skia::Matrix { + let mut matrix = self.page_transform(); + // Keep linked-image placement aligned with Skia's fragment output. + // `pre_concat` here matches the effective transform observed in emitted + // SVG from Skia fragments for rotated shapes. + matrix.pre_concat(&shape.centered_transform()); + matrix + } + + /// Creates a fragment canvas configured with the page transform + /// (scale + translate to the export bounds). + pub(super) fn new_fragment(&self) -> skia::svg::Canvas { + let canvas = skia::svg::Canvas::new(self.page_rect, None); + { + let cv: &skia::Canvas = &*canvas; + cv.scale((self.scale, self.scale)); + cv.translate((self.tx, self.ty)); + } + canvas + } + + /// Returns the current leaf-drawing canvas, creating a fragment if needed. + pub(crate) fn canvas(&mut self) -> &skia::Canvas { + if self.pending.is_none() { + self.pending = Some(self.new_fragment()); + } + self.pending.as_deref().unwrap() + } + + /// Finalizes the pending fragment and appends its markup to `out`. + pub(super) fn flush(&mut self) { + let Some(canvas) = self.pending.take() else { + return; + }; + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + if inner.trim().is_empty() { + return; + } + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + self.out.push_str(&remap_ids(inner, &prefix)); + } + + pub(super) fn open_group(&mut self, attrs: &str) { + self.flush(); + self.out.push_str("'); + } + + pub(super) fn close_group(&mut self) { + self.flush(); + self.out.push_str(""); + } + + pub(super) fn push_def(&mut self, def: &str) { + self.defs.push_str(def); + } + + /// Interns a reusable `` keyed by its (id-independent) body. If an + /// identical def was already emitted, its id is returned; otherwise a fresh + /// id is minted, the def emitted via `render(id)` and cached. Lets several + /// shapes share one filter instead of duplicating identical ``s. + pub(super) fn intern_def(&mut self, prefix: &str, key: &str, render: impl Fn(&str) -> String) -> String { + if let Some(id) = self.def_cache.get(key) { + return id.clone(); + } + let id = self.unique(prefix); + let def = render(&id); + self.push_def(&def); + self.def_cache.insert(key.to_string(), id.clone()); + id + } + + /// Emits a `` from a mask shape's geometry (in device/page space) + /// and returns nothing; the caller wraps content in + /// ``. + /// + /// A mask can be a group too. + /// Since a group has no geometry of its own, we recurse into + /// its descendants and accumulate their geometry. + pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) { + let canvas = self.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + draw_clip_geometry(cv, shape, tree, &paint); + } + self.finish_clip_path_fragment(id, canvas); + } + + /// Renders the mask subtree rooted at `mask_id` into an isolated fragment + /// and registers it as an alpha `` def, returning the def id for the + /// caller to reference via `mask="url(#id)"`. + /// + /// Unlike a geometric ``, this captures the mask's rendered + /// *alpha* (fills included, so images/gradients/soft masks compose + /// faithfully). The subtree is rendered + /// with a fresh [`SvgLayerCanvas`] and its ids are namespaced so they can't + /// collide with the surrounding document. + pub(super) fn push_alpha_mask( + &mut self, + shared: &mut ExportState, + mask_id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + ) -> Result { + let mut sub = SvgLayerCanvas::new(self.scale, self.page_rect, self.tx, self.ty); + super::render_tree(&mut sub, shared, mask_id, tree, scale, true)?; + sub.flush(); + + let prefix = format!("{}_", self.unique("m")); + let mask_ref = self.unique("mask"); + let sub_defs = remap_ids(&sub.defs, &prefix); + let sub_body = remap_ids(&sub.out, &prefix); + + if !sub_defs.is_empty() { + self.defs.push_str(&sub_defs); + } + self.defs.push_str(&format!( + "{sub_body}" + )); + Ok(mask_ref) + } + + /// Builds an alpha `` from a stroke's opaque silhouette and returns its + /// id, for confining an image-filled stroke to the stroke region. The + /// silhouette is drawn opaque (alpha 1 in the stroke area), so `mask-type: + /// alpha` shows the image exactly where the stroke paints. + pub(super) fn push_stroke_alpha_mask( + &mut self, + shared: &mut ExportState, + element: &Shape, + stroke: &Stroke, + scale: f32, + inner_clip: bool, + ) -> Result { + let canvas = self.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + cv.save(); + cv.concat(&element.centered_transform()); + let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg); + renderer.draw_stroke_silhouette(element, stroke, inner_clip)?; + cv.restore(); + } + + let id = self.unique("simask"); + self.finish_alpha_mask_fragment(&id, canvas); + Ok(id) + } + + /// Finalizes a fragment canvas as an alpha `` def: its emitted markup's + /// *alpha* becomes the mask (opaque shows, transparent hides), independent of + /// color, unlike a luminance mask, so an opaque black silhouette works. + pub(super) fn finish_alpha_mask_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + let geometry = remap_ids(inner, &prefix); + self.defs.push_str(&format!( + "{geometry}" + )); + } + + /// Finalizes a fragment canvas as a `` def: its emitted markup + /// (shape geometry or text glyph silhouette) becomes the clip geometry. + pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + let geometry = remap_ids(inner, &prefix); + self.defs.push_str(&format!( + "{geometry}" + )); + } + + /// Finalizes a fragment canvas as a luminance `` def: its emitted + /// markup becomes the mask (white shows, black hides). + pub(super) fn finish_mask_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + let geometry = remap_ids(inner, &prefix); + self.defs.push_str(&format!( + "{geometry}" + )); + } +} + +/// Draws a mask's clip geometry into `cv` (already set up with the page +/// transform). Leaf shapes contribute their own geometry under their +/// `centered_transform`; groups contribute nothing themselves but recurse into +/// their children (a group carries no geometry, and its children hold absolute +/// coordinates with their own transforms, the group transform is not +/// propagated. +fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) { + if let Type::Group(_) = &shape.shape_type { + for child_id in shape.children_ids_iter_forward(true) { + if let Some(child) = tree.get(child_id) { + draw_clip_geometry(cv, child, tree, paint); + } + } + return; + } + + cv.save(); + cv.concat(&shape.centered_transform()); + draw_shape_geometry(cv, shape, paint); + cv.restore(); +} + +/// Builds the `` attribute string for a shape's composite effects (opacity, +/// blend mode, layer blur), registering any needed ``. Returns `None` +/// when the shape needs no wrapper. +pub(super) fn effect_attrs( + builder: &mut SvgLayerCanvas, + element: &Shape, + scale: f32, +) -> Option { + let mut parts: Vec = Vec::new(); + + let opacity = element.opacity(); + if opacity < 1.0 { + parts.push(format!("opacity=\"{opacity}\"")); + } + + if let Some(css) = blend_css(element.blend_mode().0) { + parts.push(format!("style=\"mix-blend-mode:{css}\"")); + } + + if let Some(value) = layer_blur_value(element) { + let sigma = radius_to_sigma(value * scale); + let id = builder.unique("blur"); + builder.push_def(&format!( + "\ + " + )); + parts.push(format!("filter=\"url(#{id})\"")); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +/// Layer-blur radius of a shape, if it has a visible layer blur. +fn layer_blur_value(element: &Shape) -> Option { + element + .blur + .and_then(|b| (!b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0).then_some(b.value)) +} + +/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None` +/// for `SrcOver` (normal) and modes without a CSS equivalent. +fn blend_css(mode: skia::BlendMode) -> Option<&'static str> { + use skia::BlendMode::*; + Some(match mode { + Multiply => "multiply", + Screen => "screen", + Overlay => "overlay", + Darken => "darken", + Lighten => "lighten", + ColorDodge => "color-dodge", + ColorBurn => "color-burn", + HardLight => "hard-light", + SoftLight => "soft-light", + Difference => "difference", + Exclusion => "exclusion", + Hue => "hue", + Saturation => "saturation", + Color => "color", + Luminosity => "luminosity", + _ => return None, + }) +} + +/// Returns the inner body of a Skia SVG document (everything between the +/// opening `` tag and the closing ``). +fn extract_inner_svg(doc: &str) -> &str { + let start = doc + .find("').map(|e| s + e + 1)); + let end = doc.rfind(""); + match (start, end) { + (Some(s), Some(e)) if s <= e => &doc[s..e], + _ => "", + } +} + +/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…` +/// references) so ids stay unique once fragments are merged into one document. +fn remap_ids(body: &str, prefix: &str) -> String { + // Collect id definitions. + let needle = "id=\""; + let mut ids: Vec<&str> = Vec::new(); + let mut offset = 0; + while let Some(pos) = body[offset..].find(needle) { + let start = offset + pos + needle.len(); + let Some(end_rel) = body[start..].find('"') else { + break; + }; + let id = &body[start..start + end_rel]; + if !id.is_empty() { + ids.push(id); + } + offset = start + end_rel + 1; + } + + ids.sort_unstable(); + ids.dedup(); + // Longest-first so a shorter id can't collide inside a longer one; the + // quote/paren delimiters below already prevent partial matches. + ids.sort_by(|a, b| b.len().cmp(&a.len())); + + let mut out = body.to_string(); + for id in ids { + let new_id = format!("{prefix}{id}"); + out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\"")); + out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})")); + out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\"")); + } + out +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs new file mode 100644 index 0000000000..94c3dbaeac --- /dev/null +++ b/render-wasm/src/render/svg/fixtures.rs @@ -0,0 +1,411 @@ +//! GPU-free scene builders and render helpers for SVG export tests. + +use skia_safe as skia; + +use crate::render::vector::ExportState; +use crate::render::{FontStore, ImageProvider}; +use crate::shapes::{ + Fill, FontFamily, FontStyle, GrowType, ImageFill, Paragraph, Path, Rect, Segment, SolidColor, + Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan, Type, +}; +use crate::state::ShapesPool; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; + +use super::render_tree_to_svg; + +/// Default image URL used by SVG export tests. +pub(super) const TEST_IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg=="; + +/// Font URL referenced in exported SVG `@font-face` rules. +pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; + +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()); +} + +/// Minimal [`ExportState`] for tests without image draws. Image tests build +/// their own context with [`render_with_images`]. +pub(super) fn export_state(fonts: &FontStore) -> ExportState<'_> { + ExportState { + fonts, + images: None, + sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Linear, + skia::MipmapMode::Nearest, + ), + } +} + +/// Deterministic UUID from a small integer, keeping snapshots stable. +pub(super) fn uid(n: u32) -> Uuid { + uuid_from_u32_quartet(0, 0, 0, n) +} + +/// Adds a solid-filled rectangle to the pool and returns nothing; callers +/// tweak the returned shape via `pool.get_mut` when they need effects. +pub(super) fn add_solid_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); +} + +pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { + let mut fonts = FontStore::try_new().expect("font store"); + register_test_font_urls(&mut fonts); + let mut ctx = export_state(&fonts); + let bytes = render_tree_to_svg(&mut ctx, &root, pool, 1.0).expect("svg export"); + String::from_utf8(bytes).expect("utf8 svg") +} + +/// Adds a solid-filled rectangle with a single solid stroke of the given +/// kind/width/color. +pub(super) fn add_stroked_rect( + pool: &mut ShapesPool, + id: Uuid, + rect: (f32, f32, f32, f32), + fill: skia::Color, + kind: StrokeKind, + width: f32, + stroke_color: skia::Color, +) { + add_styled_stroked_rect( + pool, + id, + rect, + fill, + kind, + StrokeStyle::Solid, + width, + stroke_color, + ); +} + +/// Adds a solid-filled rectangle with a single stroke of the given +/// kind/style/width/color. +pub(super) fn add_styled_stroked_rect( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + fill: skia::Color, + kind: StrokeKind, + style: StrokeStyle, + width: f32, + stroke_color: skia::Color, +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(fill))]); + + let mut stroke = match kind { + StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None), + StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None), + StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None), + }; + stroke.fill = Fill::Solid(SolidColor(stroke_color)); + shape.add_stroke(stroke); +} + +/// A CPU-only [`ImageProvider`] returning one tiny raster image and its source +/// URL for a fixed id, so image-stroke tests need no GPU-backed `ImageStore`. +pub(super) struct FakeImages { + pub id: Uuid, + pub image: skia::Image, + pub url: &'static str, +} + +impl ImageProvider for FakeImages { + fn get_cpu_image(&mut self, id: &Uuid) -> Option { + (*id == self.id).then(|| self.image.clone()) + } + + fn source_url(&self, id: &Uuid) -> Option<&str> { + (*id == self.id).then_some(self.url) + } +} + +/// A 2×2 non-uniform raster image (kept tiny so its base64 stays small and +/// deterministic in snapshots). +pub(super) fn tiny_image() -> skia::Image { + let info = skia::ImageInfo::new_n32_premul((2, 2), None); + let mut surface = skia::surfaces::raster(&info, None, None).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia::Color::from_rgb(0x00, 0x99, 0xFF)); + let mut paint = skia::Paint::default(); + paint.set_color(skia::Color::from_rgb(0xFF, 0x33, 0x00)); + canvas.draw_rect(skia::Rect::from_xywh(0.0, 0.0, 1.0, 1.0), &paint); + canvas.draw_rect(skia::Rect::from_xywh(1.0, 1.0, 1.0, 1.0), &paint); + surface.image_snapshot() +} + +/// Renders `root` with an image provider available (image draws enabled). +pub(super) fn render_with_images( + pool: &ShapesPool, + root: Uuid, + images: &mut dyn ImageProvider, +) -> String { + let mut fonts = FontStore::try_new().expect("font store"); + register_test_font_urls(&mut fonts); + let mut ctx = ExportState { + fonts: &fonts, + images: Some(images), + sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Linear, + skia::MipmapMode::Nearest, + ), + }; + let bytes = render_tree_to_svg(&mut ctx, &root, pool, 1.0).expect("svg export"); + String::from_utf8(bytes).expect("utf8 svg") +} + +/// Applies a rotation (degrees) around the shape's center. +/// +/// SVG export composes geometry via `Shape::centered_transform()`, which reads +/// `transform` rather than the standalone `rotation` field. Tests therefore +/// need to keep both values in sync. +pub(super) fn rotate_shape(pool: &mut ShapesPool, id: Uuid, degrees: f32) { + let shape = pool.get_mut(&id).unwrap(); + shape.set_rotation(degrees); + + let mut transform = skia::Matrix::new_identity(); + transform.set_rotate(degrees, None); + shape.set_transform( + transform[0], + transform[3], + transform[1], + transform[4], + transform[2], + transform[5], + ); +} + +/// Sets the opacity of an image-filled stroke (0-255). +pub(super) fn set_image_stroke_opacity(pool: &mut ShapesPool, id: Uuid, opacity: u8) { + let shape = pool.get_mut(&id).unwrap(); + let Some(stroke) = shape.strokes.first_mut() else { + return; + }; + let Fill::Image(image_fill) = &stroke.fill else { + return; + }; + + stroke.fill = Fill::Image(ImageFill::new( + image_fill.id(), + opacity, + image_fill.width(), + image_fill.height(), + image_fill.keep_aspect_ratio(), + )); +} + +/// Adds a solid-filled rectangle whose single stroke is *image-filled* (the +/// stroke references `image_id` via an [`ImageFill`]). +pub(super) fn add_image_stroked_rect( + pool: &mut ShapesPool, + id: Uuid, + image_id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + fill: skia::Color, + kind: StrokeKind, + style: StrokeStyle, + width: f32, +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(fill))]); + + let mut stroke = match kind { + StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None), + StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None), + StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None), + }; + stroke.fill = Fill::Image(ImageFill::new(image_id, 255, 2, 2, false)); + shape.add_stroke(stroke); +} + +/// Adds a rectangle whose *fill* is an image (references `image_id` via an +/// [`ImageFill`]). +pub(super) fn add_image_filled_rect( + pool: &mut ShapesPool, + id: Uuid, + image_id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Image(ImageFill::new(image_id, 255, 2, 2, false))]); +} + +/// Adds a closed triangular path whose *fill* is an image (references `image_id` +/// via an [`ImageFill`]). +pub(super) fn add_image_filled_path( + pool: &mut ShapesPool, + id: Uuid, + image_id: Uuid, +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Path(Path::new(vec![ + Segment::MoveTo((50.0, 0.0)), + Segment::LineTo((100.0, 80.0)), + Segment::LineTo((0.0, 80.0)), + Segment::Close, + ]))); + shape.set_selrect(0.0, 0.0, 100.0, 80.0); + shape.set_fills(vec![Fill::Image(ImageFill::new(image_id, 255, 2, 2, false))]); +} + +/// Adds a solid-filled closed triangular path whose single stroke is +/// *image-filled* (references `image_id` via an [`ImageFill`]). +pub(super) fn add_image_stroked_path( + pool: &mut ShapesPool, + id: Uuid, + image_id: Uuid, + fill: skia::Color, + kind: StrokeKind, + width: f32, +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Path(Path::new(vec![ + Segment::MoveTo((50.0, 0.0)), + Segment::LineTo((100.0, 80.0)), + Segment::LineTo((0.0, 80.0)), + Segment::Close, + ]))); + shape.set_selrect(0.0, 0.0, 100.0, 80.0); + shape.set_fills(vec![Fill::Solid(SolidColor(fill))]); + + let mut stroke = match kind { + StrokeKind::Inner => { + Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Center => { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Outer => { + Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + }; + stroke.fill = Fill::Image(ImageFill::new(image_id, 255, 2, 2, false)); + shape.add_stroke(stroke); +} + +/// Adds a solid-filled closed triangular path with a single solid stroke of +/// the given kind/width/color. Closed paths honor the stroke kind (open +/// paths always render centered). +pub(super) fn add_stroked_path( + pool: &mut ShapesPool, + id: Uuid, + fill: skia::Color, + kind: StrokeKind, + width: f32, + stroke_color: skia::Color, +) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Path(Path::new(vec![ + Segment::MoveTo((50.0, 0.0)), + Segment::LineTo((100.0, 80.0)), + Segment::LineTo((0.0, 80.0)), + Segment::Close, + ]))); + shape.set_selrect(0.0, 0.0, 100.0, 80.0); + shape.set_fills(vec![Fill::Solid(SolidColor(fill))]); + + let mut stroke = match kind { + StrokeKind::Inner => { + Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Center => { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Outer => { + Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + }; + stroke.fill = Fill::Solid(SolidColor(stroke_color)); + shape.add_stroke(stroke); +} + +/// Adds a single-line text shape ("HOLA"-style) using the embedded default +/// font (registered under the nil-UUID family, weight 400), optionally with +/// a single solid stroke of the given kind. Font shaping resolves against the +/// export `FontStore` (installed for the render), so no global state or GPU +/// is needed. +pub(super) fn add_text( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + text: &str, + font_size: f32, + fill: skia::Color, + stroke: Option<(StrokeKind, f32, skia::Color)>, +) { + let bounds = skia::Rect::from_ltrb(l, t, r, b); + let mut content = TextContent::new(bounds, GrowType::Fixed); + // `line_height` is a multiplier of the font size (Skia `set_height` with + // height override), NOT an absolute pixel value. + let line_height = 1.2; + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(fill))], + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + vec![span], + )); + + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + // Set the selrect *before* the text type: `set_selrect` on a text shape + // eagerly relayouts (needing the font collection), which isn't available + // until the export installs it. The render recomputes text layout from + // the selrect anyway. + shape.set_selrect(l, t, r, b); + shape.set_shape_type(Type::Text(content)); + if let Some((kind, width, color)) = stroke { + let mut stroke = match kind { + StrokeKind::Inner => { + Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Center => { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + StrokeKind::Outer => { + Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + }; + stroke.fill = Fill::Solid(SolidColor(color)); + shape.add_stroke(stroke); + } +} diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs new file mode 100644 index 0000000000..ec50c3e79b --- /dev/null +++ b/render-wasm/src/render/svg/frames.rs @@ -0,0 +1,86 @@ +use crate::error::Result; +use crate::render::shape_renderer::ShapeRenderer; +use crate::shapes::{Shape, Stroke}; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use super::shadows::{render_container_drop_shadow, render_inner_shadows}; +use super::strokes::render_image_strokes; +use crate::render::vector::{children_paint_order, ExportState, VectorRenderer, VectorTarget}; + +pub(super) fn render_frame( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, + render_shadows: bool, +) -> Result<()> { + let matrix = element.centered_transform(); + + let effects = effect_attrs(builder, element, scale); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + // Frame drop shadow: like the GPU/PDF path, the silhouette is the frame's + // rendered content (background + strokes + children), but the descendants' + // *own* drop shadows must not bleed into it (that would trace a second, + // doubled silhouette). It is emitted as a separate shadow-only pass drawn + // behind the content, sitting outside the content clip so the offset shadow + // is not clipped to the frame bounds. + if render_shadows { + render_container_drop_shadow(builder, shared, element, tree, scale)?; + } + + let clipped = element.clip_content; + if clipped { + let clip_id = builder.unique("clip"); + builder.push_clip_path(&clip_id, element, tree); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } + + // Frame background + inner shadows (frame space). + if !element.fills.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_fills(element, &element.fills)?; + canvas.restore(); + } + + // Frame-background inner shadows (native ``; the shared renderer's + // `save_layer` version is dropped by `SkSVGDevice`). Over the background, + // under the children, matching the GPU order. + render_inner_shadows(builder, shared, element, scale)?; + + // Children (absolute coords). + for child_id in &children_paint_order(tree, element) { + render_tree(builder, shared, child_id, tree, scale, render_shadows)?; + } + + // Strokes over children (frame space). + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_strokes(element, &visible_strokes)?; + canvas.restore(); + + // Image-filled frame strokes are deferred by the shared renderer; re-emit + // the texture masked to the stroke region. + render_image_strokes(builder, shared, element, scale)?; + } + + if clipped { + builder.close_group(); + } + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs new file mode 100644 index 0000000000..fc329eef5e --- /dev/null +++ b/render-wasm/src/render/svg/groups.rs @@ -0,0 +1,56 @@ +use crate::error::Result; +use crate::shapes::Shape; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use super::shadows::render_container_drop_shadow; +use crate::render::vector::{children_paint_order, ExportState}; + +pub(super) fn render_group( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + masked: bool, + tree: ShapesPoolRef, + scale: f32, + render_shadows: bool, +) -> Result<()> { + let effects = effect_attrs(builder, element, scale); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + // Group drop shadow: the silhouette is the group's subtree (rendered + // without its descendants' own shadows), drawn behind the content. + if render_shadows { + render_container_drop_shadow(builder, shared, element, tree, scale)?; + } + + // A Penpot mask is an *alpha* mask: content is clipped to the mask shape's + // rendered alpha (geometry AND fill alpha), not merely its outline. Mirror + // the GPU/PDF `DstIn` compose with an SVG `` that + // holds the fully-rendered mask subtree, so image/gradient/soft masks work + // (a geometric `` can only capture the outline). + let mask_id = masked + .then(|| element.mask_id()) + .flatten() + .filter(|mid| tree.get(mid).is_some()) + .copied(); + if let Some(mid) = mask_id { + let mask_ref = builder.push_alpha_mask(shared, &mid, tree, scale)?; + builder.open_group(&format!("mask=\"url(#{mask_ref})\"")); + } + + for child_id in &children_paint_order(tree, element) { + render_tree(builder, shared, child_id, tree, scale, render_shadows)?; + } + + if mask_id.is_some() { + builder.close_group(); + } + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/masks.rs b/render-wasm/src/render/svg/masks.rs new file mode 100644 index 0000000000..fec3ab382c --- /dev/null +++ b/render-wasm/src/render/svg/masks.rs @@ -0,0 +1,123 @@ +use skia_safe::{self as skia, Paint}; + +use crate::error::Result; +use crate::shapes::{Shape, Stroke}; +use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget}; + +use super::document::SvgLayerCanvas; + +const MASK_CANVAS: f32 = 100_000.0; + +/// Registers a luminance `` (white canvas minus a black silhouette) and +/// returns its id, keeping only content *outside* the silhouette. +fn push_inverse_mask( + builder: &mut SvgLayerCanvas, + prefix: &str, + matrix: &skia::Matrix, + draw_silhouette: impl FnOnce(&skia::Canvas), +) -> String { + let mask_id = builder.unique(prefix); + let canvas = builder.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + let mut white = Paint::default(); + white.set_color(skia::Color::WHITE); + cv.draw_rect( + skia::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS), + &white, + ); + cv.save(); + cv.concat(matrix); + draw_silhouette(cv); + cv.restore(); + } + builder.finish_mask_fragment(&mask_id, canvas); + mask_id +} + +/// Registers a `` from a leaf shape's geometry and returns its id. +pub(super) fn push_leaf_clip_path( + builder: &mut SvgLayerCanvas, + element: &Shape, + matrix: &skia::Matrix, +) -> String { + let clip_id = builder.unique("dclip"); + let canvas = builder.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + cv.concat(matrix); + let mut black = Paint::default(); + black.set_anti_alias(true); + black.set_color(skia::Color::BLACK); + draw_shape_geometry(cv, element, &black); + } + builder.finish_clip_path_fragment(&clip_id, canvas); + clip_id +} + +/// Inverse-of-shape luminance mask: keeps only content outside the shape. +pub(super) fn push_inverse_shape_mask( + builder: &mut SvgLayerCanvas, + element: &Shape, + matrix: &skia::Matrix, + prefix: &str, +) -> String { + push_inverse_mask(builder, prefix, matrix, |cv| { + let mut black = Paint::default(); + black.set_anti_alias(true); + black.set_color(skia::Color::BLACK); + draw_shape_geometry(cv, element, &black); + }) +} + +/// Inverse-of-glyph luminance mask: keeps only content outside the text glyphs. +pub(super) fn push_inverse_glyph_mask( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + matrix: &skia::Matrix, + scale: f32, +) -> Result { + let mask_id = builder.unique("tmask"); + let canvas = builder.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + let mut white = Paint::default(); + white.set_color(skia::Color::WHITE); + cv.draw_rect( + skia::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS), + &white, + ); + cv.save(); + cv.concat(matrix); + let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg); + renderer.draw_text_glyph_silhouette(element)?; + cv.restore(); + } + builder.finish_mask_fragment(&mask_id, canvas); + Ok(mask_id) +} + +/// Alpha `` of a stroke's boundary-centered dot ring. +pub(super) fn push_dotted_ring_alpha_mask( + builder: &mut SvgLayerCanvas, + element: &Shape, + stroke: &Stroke, + matrix: &skia::Matrix, +) -> String { + let svg_attrs = element.svg_attrs.as_ref(); + let mask_id = builder.unique("dimask"); + let canvas = builder.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + cv.concat(matrix); + // Keep the dotted paint's dash effect and width, but paint it opaque so + // the alpha mask captures the exact dot footprint (fill is irrelevant). + let mut paint = stroke.to_paint(&element.selrect, svg_attrs, true); + paint.set_shader(None); + paint.set_color(skia::Color::BLACK); + draw_shape_geometry(cv, element, &paint); + } + builder.finish_alpha_mask_fragment(&mask_id, canvas); + mask_id +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs new file mode 100644 index 0000000000..0e50ac7487 --- /dev/null +++ b/render-wasm/src/render/svg/mod.rs @@ -0,0 +1,281 @@ +use std::collections::HashSet; + +use skia_safe::{self as skia}; + +use crate::error::Result; +use crate::render::vector::ExportState; +use crate::shapes::Type; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::RenderState; + +/// Collects the registered font aliases used by every text span in the subtree +/// rooted at `id`, so the exporter can emit `@font-face` rules for them. +fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet) { + let Some(shape) = tree.get(id) else { + return; + }; + + if let Type::Text(_) = &shape.shape_type { + for paragraph in shape.get_text_content().paragraphs() { + for span in paragraph.children() { + out.insert(format!("{}", span.font_family)); + } + } + } + + for child_id in shape.children_ids_iter_forward(true) { + collect_font_aliases(tree, child_id, out); + } +} + +/// Renders a shape tree to an SVG document and returns the raw SVG bytes. +/// +/// Dedicated vector-SVG render path. Leaf content (paths, text, fills, images) +/// is emitted as real SVG markup via short-lived Skia SVG canvases, while +/// composite effects (container/leaf opacity, blend mode, layer blur, masks) +/// are composed as native SVG `` wrappers (`opacity`, `mix-blend-mode`, +/// `filter="feGaussianBlur"`, `clip-path`). This keeps everything vectorial and +/// faithful to the GPU/PDF output, sidestepping `SkSVGDevice`'s inability to +/// keep `save_layer` content. +/// +/// Text is emitted as real `` elements (no `CONVERT_TEXT_TO_PATHS`), so +/// the output stays selectable/editable. Skia's SVG backend does not embed +/// fonts, so we inject `@font-face` rules that reference the source URLs +/// registered at load time. +pub fn render_to_svg( + shared: &mut RenderState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + let mut ctx = ExportState { + fonts: &shared.fonts, + images: Some(&mut shared.images as &mut dyn super::ImageProvider), + sampling_options: shared.sampling_options, + }; + render_tree_to_svg(&mut ctx, id, tree, scale) +} + +/// Core SVG export, decoupled from `RenderState` via [`ExportState`] so it can +/// run on a plain CPU Skia canvas (and in headless native tests, which cannot +/// build a GPU-backed `RenderState`). +pub(crate) fn render_tree_to_svg( + shared: &mut ExportState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + let text_ctx = shared.text_ctx(crate::utils::Browser::Chrome); + + let shape = tree + .get(id) + .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?; + let bounds = shape.extrect(tree, scale, &text_ctx); + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + + let (defs, body) = + render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; + + // Emit @font-face rules for fonts used in the subtree so viewers can load them. + let mut aliases = HashSet::new(); + collect_font_aliases(tree, id, &mut aliases); + let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + + let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); + out.push_str("\n"); + out.push_str(&format!( + "" + )); + + if !font_css.is_empty() || !defs.is_empty() { + out.push_str(""); + if !font_css.is_empty() { + out.push_str(&format!( + "" + )); + } + out.push_str(&defs); + out.push_str(""); + } + + out.push_str(&body); + out.push_str(""); + + Ok(out.into_bytes()) +} + +mod document; +mod frames; +mod groups; +mod images; +mod masks; +mod shadows; +mod strokes; +mod text; + +pub(crate) use document::SvgLayerCanvas; +pub(crate) use images::{ + emit_linked_image_fill, emit_linked_image_stroke, +}; + +use crate::render::vector::{render_leaf_content, VectorRenderer, VectorTarget}; +use crate::shapes::Shape; +use document::effect_attrs; +use frames::render_frame; +use groups::render_group; +use shadows::{drop_shadow_attr, render_inner_shadows}; +use strokes::{render_image_strokes, render_path_outer_strokes, render_rect_circle_dotted_strokes}; +use text::{render_text_alpha_strokes, render_text_inner_strokes, render_text_outer_strokes}; + +/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. +fn render_body( + shared: &mut ExportState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, +) -> Result<(String, String)> { + let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty); + render_tree(&mut builder, shared, id, tree, scale, true)?; + builder.flush(); + Ok((builder.defs, builder.out)) +} + +/// `render_shadows` is `false` while building a container's drop-shadow +/// silhouette: descendants must contribute their *shape* alpha but not their own +/// drop shadows (matching the GPU, which clears nested shadows when rendering a +/// container shadow). It is `true` for the normal document render. +fn render_tree( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + render_shadows: bool, +) -> Result<()> { + let Some(element) = tree.get(id) else { + return Ok(()); + }; + if element.hidden { + return Ok(()); + } + + match &element.shape_type { + Type::Group(group) => { + render_group(builder, shared, element, group.masked, tree, scale, render_shadows) + } + Type::Frame(_) => render_frame(builder, shared, element, tree, scale, render_shadows), + Type::Rect(_) + | Type::Circle + | Type::Path(_) + | Type::Bool(_) + | Type::Text(_) + | Type::SVGRaw(_) => render_leaf(builder, shared, element, scale, render_shadows), + } +} + +fn render_leaf( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, + render_shadows: bool, +) -> Result<()> { + let effects = effect_attrs(builder, element, scale); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + // Drop shadows sit behind (and below the opacity/blend layer of) the shape. + // `SkSVGDevice` drops the GPU/PDF `save_layer` shadow, so emit a native SVG + // filter and let it produce the shadow + the shape on top. Suppressed while + // rendering a parent container's shadow silhouette (`render_shadows` false). + let shadow = if render_shadows { + drop_shadow_attr(builder, element, scale, true) + } else { + None + }; + if let Some(attrs) = &shadow { + builder.open_group(attrs); + } + + { + let matrix = element.centered_transform(); + let mut renderer = + VectorRenderer::new_svg_layer(builder, shared, scale, VectorTarget::Svg); + { + let canvas = renderer.skia_canvas(); + canvas.save(); + canvas.concat(&matrix); + } + render_leaf_content(&mut renderer, element)?; + { + let canvas = renderer.skia_canvas(); + canvas.restore(); + } + } + + // Inner shadows compose inside a `save_layer` the SVG backend drops; re-emit + // them natively over the fill (matches the GPU fill/inner-shadow/stroke order). + render_inner_shadows(builder, shared, element, scale)?; + + // Outer strokes on closed paths/bools are deferred by the shared renderer on + // SVG (their `save_layer` + `Clear` composition is dropped by `SkSVGDevice`); + // re-emit them as a nested `` clipped to the shape's exterior. + render_path_outer_strokes(builder, element, scale)?; + + // Dotted inner/outer strokes on rect/circle are likewise deferred (their + // boundary-ring clip lives in a dropped `save_layer`); re-emit them here + // clipped/masked to the shape interior/exterior. + render_rect_circle_dotted_strokes(builder, element)?; + + // Image-filled strokes are deferred too (their `SrcIn` composition lives in a + // dropped `save_layer`); re-emit the texture masked to the stroke region. + render_image_strokes(builder, shared, element, scale)?; + + // Semi-transparent text strokes are skipped by the shared renderer on SVG + // (their opacity layer is a dropped `save_layer`); re-emit them here inside + // a native `` wrapping the fully-opaque stroke. + if let Type::Text(_) = &element.shape_type { + render_text_alpha_strokes(builder, shared, element, scale)?; + render_text_inner_strokes(builder, shared, element, scale)?; + render_text_outer_strokes(builder, shared, element, scale)?; + } + + if shadow.is_some() { + builder.close_group(); + } + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} + +// =========================================================================== +// Tests +// =========================================================================== +// +// These are fast, headless native tests (`cargo test --bin render_wasm`) for +// the SVG exporter. They bypass the GPU/browser stack entirely: shapes are +// built directly into a `ShapesPool` and rendered through +// [`render_tree_to_svg`] with a GPU-free [`ExportState`] (standalone +// [`FontStore`]; image tests use [`FakeImages`] via [`render_with_images`]). +// Output is checked with `insta` snapshots. +// +// To (re)generate snapshots after a deliberate change: +// cargo insta test --accept --bin render_wasm +// (or run the tests and `cargo insta accept`). +#[cfg(test)] +mod fixtures; + +#[cfg(test)] +mod tests; + diff --git a/render-wasm/src/render/svg/shadows.rs b/render-wasm/src/render/svg/shadows.rs new file mode 100644 index 0000000000..a017a9ea35 --- /dev/null +++ b/render-wasm/src/render/svg/shadows.rs @@ -0,0 +1,307 @@ +use skia_safe::{self as skia, Paint}; + +use crate::error::Result; +use crate::render::shape_renderer::ShapeRenderer; +use crate::shapes::{radius_to_sigma, Shadow, Shape, Stroke, Type}; +use crate::state::ShapesPoolRef; + +use super::document::SvgLayerCanvas; +use super::render_tree; +use crate::render::vector::{children_paint_order, draw_shape_geometry, ExportState, VectorRenderer, VectorTarget}; + +/// Adaptive `` region as `objectBoundingBox` percentages. +struct FilterRegion { + x: f32, + y: f32, + width: f32, + height: f32, +} + +fn filter_region(bbox: skia::Rect, scale: f32, margin_x: f32, margin_y: f32) -> FilterRegion { + let w = (bbox.width() * scale).abs().max(1.0); + let h = (bbox.height() * scale).abs().max(1.0); + FilterRegion { + x: -margin_x / w * 100.0, + y: -margin_y / h * 100.0, + width: (1.0 + 2.0 * margin_x / w) * 100.0, + height: (1.0 + 2.0 * margin_y / h) * 100.0, + } +} + +fn track_shadow_reach( + margin_x: f32, + margin_y: f32, + sigma: f32, + spread: f32, + scale: f32, + dx: f32, + dy: f32, +) -> (f32, f32) { + let reach = 3.0 * sigma + spread * scale; + ( + margin_x.max(reach + dx.abs()), + margin_y.max(reach + dy.abs()), + ) +} + +fn shadow_tint(shadow: &Shadow) -> (String, f32) { + let color = shadow.color; + ( + format!("#{:02X}{:02X}{:02X}", color.r(), color.g(), color.b()), + color.a() as f32 / 255.0, + ) +} + +fn intern_filter_attr( + builder: &mut SvgLayerCanvas, + prefix: &str, + region: &FilterRegion, + prims: &str, + merges: &str, +) -> String { + let body = format!( + "x=\"{:.4}%\" y=\"{:.4}%\" width=\"{:.4}%\" height=\"{:.4}%\" \ + primitiveUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\">\ + {prims}{merges}", + region.x, region.y, region.width, region.height, + ); + let id = builder.intern_def(prefix, &body, |id| format!("")); + format!("filter=\"url(#{id})\"") +} + +/// Emits a container's (frame/group) drop shadows as a shadow-only `` +/// group drawn *behind* the content. The group holds the container's silhouette: +/// its own fills/strokes plus its descendants rendered with `render_shadows = +/// false`, so the filter's `SourceAlpha` is the shape silhouette *without* the +/// descendants' own drop shadows (which would otherwise cast a second, offset +/// green copy: the "doubled shadow"). The filter merges only the tinted/offset +/// shadow (no `SourceGraphic`), since the real content is drawn separately on +/// top. +/// +/// For a frame, the descendants' silhouette is clipped to the frame geometry, +/// mirroring the GPU (`get_nested_shadow_clip_bounds` clips each child's shadow +/// to the frame selrect). The frame's *own* fills/strokes are left unclipped +/// (the GPU renders them with `clip_content = false`). The clip is applied +/// pre-offset; the filter's `feOffset` then shifts the clipped silhouette, +/// matching the GPU's selrect-shifted-by-offset clip. +pub(super) fn render_container_drop_shadow( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let Some(attrs) = drop_shadow_attr(builder, element, scale, false) else { + return Ok(()); + }; + + let matrix = element.centered_transform(); + builder.open_group(&attrs); + + // Frame background silhouette (frame space, unclipped). + if !element.fills.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_fills(element, &element.fills)?; + canvas.restore(); + } + + // Descendants (absolute coords), with their own shadows suppressed. Clipped + // to the frame geometry to match the GPU nested-shadow clip. + let children = children_paint_order(tree, element); + let clip_children = !children.is_empty() && matches!(element.shape_type, Type::Frame(_)); + if clip_children { + let clip_id = builder.unique("clip"); + builder.push_clip_path(&clip_id, element, tree); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale, false)?; + } + if clip_children { + builder.close_group(); + } + + // Frame strokes silhouette (frame space, unclipped). + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_strokes(element, &visible_strokes)?; + canvas.restore(); + } + + builder.close_group(); + Ok(()) +} + +/// Registers an SVG `` reproducing the shape's visible drop shadows +/// (Penpot paints them behind the shape) and returns the `` +/// attribute referencing it, or `None` when there are none. +pub(super) fn drop_shadow_attr( + builder: &mut SvgLayerCanvas, + element: &Shape, + scale: f32, + include_source: bool, +) -> Option { + let shadows: Vec<&Shadow> = element.drop_shadows_visible().collect(); + if shadows.is_empty() { + return None; + } + + let mut margin_x = 0.0_f32; + let mut margin_y = 0.0_f32; + let mut prims = String::new(); + let mut merges = String::new(); + + for (i, shadow) in shadows.iter().enumerate() { + let sigma = radius_to_sigma(shadow.blur * scale); + let dx = shadow.offset.0 * scale; + let dy = shadow.offset.1 * scale; + (margin_x, margin_y) = track_shadow_reach(margin_x, margin_y, sigma, shadow.spread, scale, dx, dy); + let (hex, opacity) = shadow_tint(shadow); + + let mut input = "SourceAlpha".to_string(); + if shadow.spread > 0.0 { + let dilated = format!("shsp{i}"); + prims.push_str(&format!( + "", + shadow.spread * scale + )); + input = dilated; + } + let blurred = format!("shbl{i}"); + prims.push_str(&format!( + "" + )); + let offset = format!("shof{i}"); + prims.push_str(&format!( + "" + )); + let flood = format!("shfl{i}"); + prims.push_str(&format!( + "" + )); + let tinted = format!("shad{i}"); + prims.push_str(&format!( + "" + )); + merges.push_str(&format!("")); + } + if include_source { + merges.push_str(""); + } + + let region = filter_region(element.selrect(), scale, margin_x, margin_y); + Some(intern_filter_attr(builder, "shadow", ®ion, &prims, &merges)) +} + +/// Builds an `filter="url(#…)"` attribute for a shape's *inner* shadows. +fn inner_shadow_attr( + builder: &mut SvgLayerCanvas, + element: &Shape, + scale: f32, +) -> Option { + let shadows: Vec<&Shadow> = element.inner_shadows_visible().collect(); + if shadows.is_empty() { + return None; + } + + let mut margin_x = 0.0_f32; + let mut margin_y = 0.0_f32; + let mut prims = String::new(); + let mut merges = String::new(); + + for (i, shadow) in shadows.iter().enumerate() { + let sigma = radius_to_sigma(shadow.blur * scale); + let dx = shadow.offset.0 * scale; + let dy = shadow.offset.1 * scale; + (margin_x, margin_y) = track_shadow_reach(margin_x, margin_y, sigma, shadow.spread, scale, dx, dy); + let (hex, opacity) = shadow_tint(shadow); + + let blurred = format!("isbl{i}"); + prims.push_str(&format!( + "" + )); + let occluder = format!("isof{i}"); + prims.push_str(&format!( + "" + )); + let band = format!("isbd{i}"); + prims.push_str(&format!( + "" + )); + let flood = format!("isfl{i}"); + prims.push_str(&format!( + "" + )); + let tinted = format!("istn{i}"); + prims.push_str(&format!( + "" + )); + let mut clip_input = tinted.clone(); + if shadow.spread > 0.0 { + let dilated = format!("issp{i}"); + prims.push_str(&format!( + "", + shadow.spread * scale + )); + clip_input = dilated; + } + let shade = format!("issh{i}"); + prims.push_str(&format!( + "" + )); + merges.push_str(&format!("")); + } + + let region = filter_region(element.selrect(), scale, margin_x, margin_y); + Some(intern_filter_attr(builder, "inshadow", ®ion, &prims, &merges)) +} + +/// Emits a shape's inner shadows as a `` wrapping an opaque silhouette +/// (the shape geometry, or the glyph silhouette for text) drawn over the fill. +pub(super) fn render_inner_shadows( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, +) -> Result<()> { + let is_text = matches!(element.shape_type, Type::Text(_)); + if !is_text && !element.has_fills() { + return Ok(()); + } + let Some(attrs) = inner_shadow_attr(builder, element, scale) else { + return Ok(()); + }; + + builder.open_group(&attrs); + { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + if is_text { + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_text_glyph_silhouette(element)?; + } else { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + draw_shape_geometry(canvas, element, &paint); + } + canvas.restore(); + } + builder.close_group(); + Ok(()) +} diff --git a/render-wasm/src/render/svg/strokes.rs b/render-wasm/src/render/svg/strokes.rs new file mode 100644 index 0000000000..c3e512e8e5 --- /dev/null +++ b/render-wasm/src/render/svg/strokes.rs @@ -0,0 +1,243 @@ +use skia_safe::{self as skia}; + +use crate::error::Result; +use crate::shapes::{Fill, Shape, StrokeKind, Type}; + +use super::document::SvgLayerCanvas; +use super::masks::{push_dotted_ring_alpha_mask, push_inverse_shape_mask, push_leaf_clip_path}; +use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget}; + +/// Alpha mask of a path/bool stroked at double width (no inner clear). Combined +/// with an exterior luminance mask this yields only the outer stroke band, the +/// same composition `render_path_outer_strokes` uses for solid strokes, without +/// relying on `stroke_to_path` boolean ops (which emit even-odd self-intersections +/// in SVG for rotated paths). +fn push_path_double_stroke_alpha_mask( + builder: &mut SvgLayerCanvas, + element: &Shape, + stroke: &crate::shapes::Stroke, +) -> String { + let svg_attrs = element.svg_attrs.as_ref(); + let canvas = builder.new_fragment(); + { + let cv: &skia::Canvas = &*canvas; + cv.save(); + cv.concat(&element.centered_transform()); + let mut paint = stroke.to_stroked_paint(false, &element.selrect, svg_attrs, true); + paint.set_shader(None); + paint.set_color(skia::Color::BLACK); + draw_shape_geometry(cv, element, &paint); + cv.restore(); + } + + let id = builder.unique("simask"); + builder.finish_alpha_mask_fragment(&id, canvas); + id +} + +/// Re-emits the *outer* solid/gradient strokes of a closed path/bool as a +/// nested `` whose content (the shape stroked at double width) is clipped to +/// the shape's *exterior* via a luminance `` (white canvas minus the +/// shape silhouette). This mirrors the GPU/PDF `save_layer` + `Clear` (DstOut of +/// the shape), which `SkSVGDevice` drops, so the shared renderer skips these on +/// SVG and lets the compositor nest them here. +/// +/// Image-fill outer strokes on path/bool are handled by `render_image_strokes` +/// (same exterior-mask trick; see there for why). +pub(super) fn render_path_outer_strokes( + builder: &mut SvgLayerCanvas, + element: &Shape, + _scale: f32, +) -> Result<()> { + if !matches!(element.shape_type, Type::Path(_) | Type::Bool(_)) || element.is_open() { + return Ok(()); + } + + let matrix = element.centered_transform(); + for stroke in element.visible_strokes() { + if stroke.render_kind(false) != StrokeKind::Outer { + continue; + } + // Image-filled outer strokes are composed by `render_image_strokes` via + // an alpha mask of the stroke region; don't re-emit them here as a + // solid stroke (it would show up as a second outline). + if matches!(stroke.fill, Fill::Image(_)) { + continue; + } + + let mask_id = push_inverse_shape_mask(builder, element, &matrix, "smask"); + builder.open_group(&format!("mask=\"url(#{mask_id})\"")); + { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let svg_attrs = element.svg_attrs.as_ref(); + let paint = stroke.to_stroked_paint(false, &element.selrect, svg_attrs, true); + draw_shape_geometry(canvas, element, &paint); + canvas.restore(); + } + builder.close_group(); + } + Ok(()) +} + +/// Re-emits *image-filled* strokes. On the GPU/PDF path the texture is confined +/// to the stroke with a `save_layer` + `SrcIn` over the stroke silhouette, which +/// `SkSVGDevice` drops, so the shared renderer skips them on SVG and the +/// compositor draws the image under a `` built from that silhouette. +/// +/// - Solid/center strokes: the silhouette is the stroke geometry, used directly +/// as the mask. +/// - Dotted inner/outer strokes: the silhouette is the boundary-centered dot +/// ring; the image is drawn under that ring mask *and* inside a second group +/// that restricts it to the shape interior (inner) or exterior (outer), since +/// the ring straddles the boundary. +/// +/// When no [`ImageProvider`] is available there is nothing to draw. +pub(super) fn render_image_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, +) -> Result<()> { + if shared.images.is_none() { + return Ok(()); + } + + let matrix = element.centered_transform(); + for stroke in element.visible_strokes() { + if !matches!(stroke.fill, Fill::Image(_)) { + continue; + } + + match stroke.clip_op() { + // Solid / dotted-center: mask straight to the stroke silhouette. + None => { + if stroke.render_kind(false) == StrokeKind::Outer + && matches!(element.shape_type, Type::Path(_) | Type::Bool(_)) + && !element.is_open() + { + // Exterior (hide shape interior) ∩ double-width stroke silhouette. + let ext_id = push_inverse_shape_mask(builder, element, &matrix, "smask"); + builder.open_group(&format!("mask=\"url(#{ext_id})\"")); + let ring_id = push_path_double_stroke_alpha_mask(builder, element, stroke); + builder.open_group(&format!("mask=\"url(#{ring_id})\"")); + draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?; + builder.close_group(); // ring + builder.close_group(); // exterior + } else if stroke.render_kind(false) == StrokeKind::Inner + && matches!(element.shape_type, Type::Path(_) | Type::Bool(_)) + && !element.is_open() + { + // `clip_to_shape` inside a mask fragment serializes without the + // canvas CTM, so restrict to the rotated interior with a native + // `` and draw the double-width stroke unclipped. + let clip_id = push_leaf_clip_path(builder, element, &matrix); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + let mask_id = + builder.push_stroke_alpha_mask(shared, element, stroke, scale, false)?; + builder.open_group(&format!("mask=\"url(#{mask_id})\"")); + draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?; + builder.close_group(); // stroke + builder.close_group(); // interior + } else { + let mask_id = + builder.push_stroke_alpha_mask(shared, element, stroke, scale, true)?; + builder.open_group(&format!("mask=\"url(#{mask_id})\"")); + draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?; + builder.close_group(); + } + } + // Dotted inner/outer: restrict to interior/exterior, then to the ring. + Some(clip_op) => { + if clip_op == skia::ClipOp::Intersect { + let clip_id = push_leaf_clip_path(builder, element, &matrix); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } else { + let mask_id = push_inverse_shape_mask(builder, element, &matrix, "dmask"); + builder.open_group(&format!("mask=\"url(#{mask_id})\"")); + } + let ring_id = push_dotted_ring_alpha_mask(builder, element, stroke, &matrix); + builder.open_group(&format!("mask=\"url(#{ring_id})\"")); + draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?; + builder.close_group(); // ring + builder.close_group(); // interior/exterior restriction + } + } + } + Ok(()) +} + +/// Draws an image-filled stroke's texture over its destination rect into the +/// current group (the caller supplies the mask/clip that confines it). +fn draw_stroke_image( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + stroke: &crate::shapes::Stroke, + scale: f32, + matrix: &skia::Matrix, +) -> Result<()> { + let mut renderer = VectorRenderer::new_svg_layer(builder, shared, scale, VectorTarget::Svg); + { + let canvas = renderer.skia_canvas(); + canvas.save(); + canvas.concat(matrix); + } + renderer.draw_stroke_image(element, stroke)?; + { + let canvas = renderer.skia_canvas(); + canvas.restore(); + } + Ok(()) +} + +/// Re-emits the *dotted inner/outer* strokes of a rect/circle. On the GPU/PDF +/// path these stamp a ring of dots centered on the shape boundary and clip it +/// to the shape interior (inner) or exterior (outer) inside a `save_layer` that +/// `SkSVGDevice` drops, so the shared renderer skips them on SVG and the +/// compositor re-emits the same ring of dots here, clipped natively: a +/// `` (inner) or a `` with the inverse-of-shape luminance +/// mask (outer, since SVG `` cannot subtract). +pub(super) fn render_rect_circle_dotted_strokes( + builder: &mut SvgLayerCanvas, + element: &Shape, +) -> Result<()> { + if !matches!(element.shape_type, Type::Rect(_) | Type::Circle) { + return Ok(()); + } + + let matrix = element.centered_transform(); + let svg_attrs = element.svg_attrs.as_ref(); + + for stroke in element.visible_strokes() { + let Some(clip_op) = stroke.clip_op() else { + continue; + }; + // Image-filled dotted strokes are re-emitted by `render_image_strokes` + // (the dots must be filled with the texture, not a solid paint). + if matches!(stroke.fill, Fill::Image(_)) { + continue; + } + + let paint = stroke.to_paint(&element.selrect, svg_attrs, true); + + if clip_op == skia::ClipOp::Intersect { + let clip_id = push_leaf_clip_path(builder, element, &matrix); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } else { + let mask_id = push_inverse_shape_mask(builder, element, &matrix, "dmask"); + builder.open_group(&format!("mask=\"url(#{mask_id})\"")); + } + + { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + draw_shape_geometry(canvas, element, &paint); + canvas.restore(); + } + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/tests/mod.rs b/render-wasm/src/render/svg/tests/mod.rs new file mode 100644 index 0000000000..95b8152208 --- /dev/null +++ b/render-wasm/src/render/svg/tests/mod.rs @@ -0,0 +1,1194 @@ +use super::fixtures::*; + +use crate::shapes::{ + BlendMode, Blur, BlurType, Fill, Frame, Group, Rect, Shadow, ShadowStyle, SolidColor, Stroke, + StrokeKind, StrokeStyle, Type, +}; +use crate::state::ShapesPool; +use crate::uuid::Uuid; + +use skia_safe as skia; + +#[test] +fn exports_a_solid_rect() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + + insta::assert_snapshot!(render(&pool, id)); +} + +#[test] +fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(0, 128, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_opacity(0.5); + shape.set_blend_mode(BlendMode(skia::BlendMode::Multiply)); + } + + let svg = render(&pool, id); + // Composite effects must become native SVG group wrappers, not dropped + // `save_layer`s. + assert!(svg.contains("opacity=\"0.5\""), "missing opacity wrapper: {svg}"); + assert!( + svg.contains("mix-blend-mode:multiply"), + "missing blend-mode wrapper: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_group_with_two_rects_and_group_opacity() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let a = uid(2); + let b = uid(3); + + { + let group = pool.add_shape(group_id); + group.set_parent(Uuid::nil()); + group.set_shape_type(Type::Group(Group { masked: false })); + group.set_selrect(0.0, 0.0, 200.0, 100.0); + group.set_opacity(0.7); + group.add_child(a); + group.add_child(b); + } + + add_solid_rect( + &mut pool, + a, + group_id, + (0.0, 0.0, 90.0, 100.0), + skia::Color::from_rgb(0, 0, 255), + ); + add_solid_rect( + &mut pool, + b, + group_id, + (110.0, 0.0, 200.0, 100.0), + skia::Color::from_rgb(0, 200, 0), + ); + + insta::assert_snapshot!(render(&pool, group_id)); +} + +/// A minimal masked group: the first child is a solid rectangle acting as +/// the mask, clipping a single solid-filled rectangle to its rendered +/// alpha. A Penpot mask is an alpha mask, so the mask needs a fill; an +/// opaque solid fill clips exactly to the mask's geometry. +#[test] +fn exports_a_masked_group() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask = uid(2); // first child, the mask + let content = uid(3); + + { + let group = pool.add_shape(group_id); + group.set_parent(Uuid::nil()); + group.set_shape_type(Type::Group(Group { masked: true })); + group.set_selrect(0.0, 0.0, 100.0, 100.0); + // First child is the mask; the rest is the masked content. + group.add_child(mask); + group.add_child(content); + } + + // Mask: a solid rectangle covering the left half of the group. Its + // opaque alpha defines the clip region. + add_solid_rect( + &mut pool, + mask, + group_id, + (0.0, 0.0, 50.0, 100.0), + skia::Color::from_rgb(0, 0, 0), + ); + + // Masked content: a solid rectangle spanning the whole group. + add_solid_rect( + &mut pool, + content, + group_id, + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(255, 0, 0), + ); + + insta::assert_snapshot!(render(&pool, group_id)); +} + +/// A masked group whose *mask is itself a group* of shapes (a very common +/// Penpot pattern). The mask region is the union of the mask group's +/// descendants (here a horizontal + vertical band forming a plus sign) +/// and the masked content is an ellipse. +/// +/// Regression test: a group carries no geometry of its own, so the SVG +/// exporter must render the whole mask subtree into the ``. Otherwise +/// the mask comes out empty and the content renders unmasked (or fully +/// hidden). +/// +/// Mirrors the transit shape (with small, round coordinates): +/// group (masked-group=true) with children +/// [ group (the mask) with [Rectangle #B1B2B5, Rectangle #B1B2B5], +/// Ellipse #1a4de5 (content) ] +#[test] +fn exports_a_masked_group_whose_mask_is_a_group() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_group = uid(2); // first child, the mask (a group) + let ellipse = uid(3); // masked content + let band_h = uid(4); // horizontal band (mask geometry) + let band_v = uid(5); // vertical band (mask geometry) + + { + let group = pool.add_shape(group_id); + group.set_parent(Uuid::nil()); + group.set_shape_type(Type::Group(Group { masked: true })); + group.set_selrect(0.0, 0.0, 100.0, 100.0); + // First child is the mask group; the rest is the masked content. + group.add_child(mask_group); + group.add_child(ellipse); + } + + // Mask: a group of two rectangles forming a plus sign. No geometry of + // its own; its mask region comes from its children. + { + let group = pool.add_shape(mask_group); + group.set_parent(group_id); + group.set_shape_type(Type::Group(Group { masked: false })); + group.set_selrect(0.0, 0.0, 100.0, 100.0); + group.add_child(band_h); + group.add_child(band_v); + } + add_solid_rect( + &mut pool, + band_h, + mask_group, + (0.0, 40.0, 100.0, 60.0), + skia::Color::from_rgb(0xB1, 0xB2, 0xB5), + ); + add_solid_rect( + &mut pool, + band_v, + mask_group, + (40.0, 0.0, 60.0, 100.0), + skia::Color::from_rgb(0xB1, 0xB2, 0xB5), + ); + + // Masked content: an ellipse (oval from its selrect). + { + let shape = pool.add_shape(ellipse); + shape.set_parent(group_id); + shape.set_shape_type(Type::Circle); + shape.set_selrect(0.0, 0.0, 100.0, 100.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0x1A, 0x4D, 0xE5, + )))]); + } + + let svg = render(&pool, group_id); + // The mask is a group, so the `` must contain the descendants' + // rendered content: the content group references a real mask and the + // mask def is not empty. + assert!( + svg.contains("mask=\"url(#"), + "content must reference a mask: {svg}" + ); + assert!( + !svg.contains("mask-type=\"alpha\">"), + "mask from a group must not be empty: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// A simple leaf shape with a drop shadow: a solid-filled ellipse with one +/// visible drop shadow (offset, blur, tint). The GPU/PDF path draws the +/// shadow via a `save_layer` image filter, which `SkSVGDevice` drops, so +/// the SVG backend must emit a native `` (blur + offset + flood) +/// producing the shadow behind the shape. +/// +/// Mirrors the transit shape (with small, round coordinates): +/// Ellipse #ffbbbb with drop-shadow +/// { color #380000, opacity 1, offset (10, 4), blur 4, spread 0 } +#[test] +fn exports_a_shape_with_a_drop_shadow() { + let mut pool = ShapesPool::new(); + let id = uid(1); + + { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Circle); + shape.set_selrect(0.0, 0.0, 100.0, 80.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xFF, 0xBB, 0xBB, + )))]); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(0xFF, 0x38, 0x00, 0x00), + 4.0, // blur + 0.0, // spread + (10.0, 4.0), // offset (x, y) + ShadowStyle::Drop, + false, // hidden + )); + } + + let svg = render(&pool, id); + // The drop shadow must survive as a native SVG filter (the GPU/PDF + // `save_layer` shadow is dropped by `SkSVGDevice`). + assert!( + svg.contains("` wrapper (`stdDeviation` from `radius_to_sigma`). +#[test] +fn exports_a_shape_with_a_layer_blur() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(0, 128, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 8.0))); + } + + let svg = render(&pool, id); + // The layer blur must survive as a native SVG filter (the GPU/PDF + // `save_layer` blur is dropped by `SkSVGDevice`). + assert!( + svg.contains("` wrapping the +/// frame content, sitting outside the content clip so the offset shadow is +/// not clipped to the frame bounds. +/// +/// Mirrors the transit shape: a 354x204 board, inner 10px black stroke, drop +/// shadow { color #000000 opacity 0.2, offset (40, 40), blur 4, spread 0 }. +#[test] +fn exports_a_frame_with_a_drop_shadow() { + let mut pool = ShapesPool::new(); + let id = uid(1); + { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Frame(Frame { + corners: None, + layout: None, + })); + shape.set_selrect(0.0, 0.0, 354.0, 204.0); + let mut stroke = Stroke::new_inner_stroke(10.0, StrokeStyle::Solid, None, None, None, None); + stroke.fill = Fill::Solid(SolidColor(skia::Color::from_rgb(0, 0, 0))); + shape.add_stroke(stroke); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(0x33, 0x00, 0x00, 0x00), // #000000 @ ~0.2 + 4.0, // blur + 0.0, // spread + (40.0, 40.0), // offset (x, y) + ShadowStyle::Drop, + false, // hidden + )); + } + + let svg = render(&pool, id); + // The frame drop shadow must survive as a native SVG filter (the GPU/PDF + // `save_layer` shadow is dropped by `SkSVGDevice`). + assert!( + svg.contains("`s, no doubled frame silhouette). + assert_eq!( + svg.matches("flood-color=\"#58EA66\"").count(), + 1, + "frame (green) shadow filter must be defined once: {svg}" + ); + assert_eq!( + svg.matches("flood-color=\"#1900FF\"").count(), + 1, + "child (blue) shadow filter must be defined once: {svg}" + ); + // The child's own shadow must not leak into the frame silhouette: it is + // referenced by exactly one `` (the real content), while the + // silhouette pass renders the child with shadows suppressed. + assert_eq!( + svg.matches("filter=\"url(#shadow").count(), + 2, + "exactly the frame-silhouette + child-content shadow refs: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// A simple rectangle with a solid *inner* stroke: the stroke sits fully +/// inside the shape's geometry. +#[test] +fn exports_a_shape_with_a_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Inner, + 10.0, + skia::Color::from_rgb(0xFF, 0x00, 0x00), + ); + + let svg = render(&pool, id); + // Skia's SVG backend serializes colors as CSS names / short hex, so + // assert on the emitted stroke rather than an exact hex string. + assert!( + svg.contains("stroke-width=\"10\"") && svg.contains("stroke=\"red\""), + "inner stroke must be present: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// A simple rectangle with a solid *center* stroke: the stroke straddles the +/// geometry (half inside, half outside). +#[test] +fn exports_a_shape_with_a_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Center, + 10.0, + skia::Color::from_rgb(0x00, 0x88, 0x00), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke-width=\"10\"") && svg.contains("stroke=\"#080\""), + "center stroke must be present: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// A simple rectangle with a solid *outer* stroke: the stroke sits fully +/// outside the shape's geometry. +#[test] +fn exports_a_shape_with_a_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Outer, + 10.0, + skia::Color::from_rgb(0x00, 0x00, 0xFF), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke-width=\"10\"") && svg.contains("stroke=\"blue\""), + "outer stroke must be present: {svg}" + ); + insta::assert_snapshot!(svg); +} + +// -- image-filled strokes ------------------------------------------------ +// +// Image-filled strokes are the trickiest SVG case: the GPU/PDF path paints +// the texture inside a `save_layer` + `SrcIn` composition that `SkSVGDevice` +// drops. The SVG compositor instead emits the raw `` confined by an +// alpha `` of the stroke silhouette (solid/dotted-center) or, for +// dotted inner/outer, an interior/exterior restriction plus a dotted-ring +// alpha mask. These tests exercise both branches with a GPU-free +// [`ImageProvider`] so they stay fast and headless. + +/// A rectangle with a solid, image-filled *center* stroke: the texture is +/// re-emitted as an `` confined by an alpha `` of the stroke +/// silhouette (no dropped `save_layer`). +#[test] +fn exports_a_shape_with_a_solid_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_stroked_rect( + &mut pool, + id, + image_id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Center, + StrokeStyle::Solid, + 16.0, + ); + + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + let svg = render_with_images(&pool, id, &mut images); + assert!(svg.contains("` cannot subtract). +#[test] +fn exports_a_shape_with_a_dotted_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_stroked_rect( + &mut pool, + id, + image_id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Outer, + StrokeStyle::Dotted, + 16.0, + ); + + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + let svg = render_with_images(&pool, id, &mut images); + assert!(svg.contains(". + assert!( + svg.contains("mask=\"url(#") && svg.contains("mask-type=\"alpha\""), + "dotted-outer image stroke needs restriction + alpha ring masks: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// A rectangle with a solid, image-filled *inner* stroke: like the center +/// case, the texture is confined by an alpha `` of the stroke +/// silhouette, but the silhouette sits fully inside the shape geometry. +#[test] +fn exports_a_shape_with_a_solid_inner_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_stroked_rect( + &mut pool, + id, + image_id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Inner, + StrokeStyle::Solid, + 16.0, + ); + + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + let svg = render_with_images(&pool, id, &mut images); + assert!(svg.contains("`. +#[test] +fn exports_a_shape_with_a_solid_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_stroked_rect( + &mut pool, + id, + image_id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Outer, + StrokeStyle::Solid, + 16.0, + ); + + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + let svg = render_with_images(&pool, id, &mut images); + assert!(svg.contains("` must stay aligned with the alpha masks +// built from `centered_transform()`. Paths with outer strokes additionally +// need the exterior + ring mask pair (no `stroke_to_path` even-odd holes). +// Stroke image opacity at 50% exercises the `opacity` attribute on ``. + +const ROTATION_45: f32 = 45.0; +const HALF_IMAGE_STROKE_OPACITY: u8 = 128; + +fn assert_solid_image_stroke_exports(svg: &str, kind: StrokeKind, is_path: bool, rotated: bool) { + assert!(svg.contains(" String { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_stroked_rect( + &mut pool, + id, + image_id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + kind, + StrokeStyle::Solid, + 16.0, + ); + rotate_shape(&mut pool, id, ROTATION_45); + set_image_stroke_opacity(&mut pool, id, HALF_IMAGE_STROKE_OPACITY); + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + render_with_images(&pool, id, &mut images) +} + +#[test] +fn exports_a_rotated_rect_with_a_solid_inner_image_stroke() { + let svg = render_rotated_rect_image_stroke(StrokeKind::Inner); + assert_solid_image_stroke_exports(&svg, StrokeKind::Inner, false, true); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_rotated_rect_with_a_solid_center_image_stroke() { + let svg = render_rotated_rect_image_stroke(StrokeKind::Center); + assert_solid_image_stroke_exports(&svg, StrokeKind::Center, false, true); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_rotated_rect_with_a_solid_outer_image_stroke() { + let svg = render_rotated_rect_image_stroke(StrokeKind::Outer); + assert_solid_image_stroke_exports(&svg, StrokeKind::Outer, false, true); + insta::assert_snapshot!(svg); +} + +/// A rectangle filled with an image: the texture is emitted as an `` +/// clipped to the shape geometry (no dropped `save_layer`). +#[test] +fn exports_a_shape_with_an_image_fill() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(9); + add_image_filled_rect(&mut pool, id, image_id, (0.0, 0.0, 100.0, 80.0)); + + let mut images = FakeImages { + id: image_id, + image: tiny_image(), + url: TEST_IMAGE_URL, + }; + let svg = render_with_images(&pool, id, &mut images); + assert!(svg.contains(" geometry: {svg}" + ); + assert!( + !svg.contains(", not : {svg}" + ); +} + +/// A simple closed path (triangle) with a solid *inner* stroke. +#[test] +fn exports_a_path_with_a_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_path( + &mut pool, + id, + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Inner, + 10.0, + skia::Color::from_rgb(0xFF, 0x00, 0x00), + ); + + insta::assert_snapshot!(render(&pool, id)); +} + +/// A simple closed path (triangle) with a solid *center* stroke. +#[test] +fn exports_a_path_with_a_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_path( + &mut pool, + id, + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Center, + 10.0, + skia::Color::from_rgb(0x00, 0x88, 0x00), + ); + + insta::assert_snapshot!(render(&pool, id)); +} + +/// A simple closed path (triangle) with a solid *outer* stroke. +#[test] +fn exports_a_path_with_a_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_path( + &mut pool, + id, + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Outer, + 10.0, + skia::Color::from_rgb(0x00, 0x00, 0xFF), + ); + + let svg = render(&pool, id); + // The outer stroke is composed as a nested `` masked to the shape's + // exterior (double-width stroke = 2 * 10). It must not vanish. + assert!( + svg.contains("`. +#[test] +fn exports_a_shape_with_a_dotted_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_styled_stroked_rect( + &mut pool, + id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Inner, + StrokeStyle::Dotted, + 10.0, + skia::Color::from_rgb(0xFF, 0x00, 0x00), + ); + + let svg = render(&pool, id); + // The dotted inner stroke must be composed as a nested `` clipped to + // the shape interior (it must not vanish with the dropped `save_layer`). + assert!( + svg.contains("` cannot subtract). +#[test] +fn exports_a_shape_with_a_dotted_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_styled_stroked_rect( + &mut pool, + id, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0xCC, 0xCC, 0xCC), + StrokeKind::Outer, + StrokeStyle::Dotted, + 10.0, + skia::Color::from_rgb(0x00, 0x00, 0xFF), + ); + + let svg = render(&pool, id); + // The dotted outer stroke must be composed as a nested `` masked to + // the shape exterior (it must not vanish with the dropped `save_layer`). + assert!( + svg.contains("`). +#[test] +fn exports_text_with_a_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, // large font + thin stroke so the fill shows inside the glyph stems + skia::Color::from_rgb(0xE1, 0x7F, 0xDA), // pink fill + Some((StrokeKind::Inner, 2.0, skia::Color::from_rgb(0x00, 0x00, 0xFF))), // blue stroke + ); + + let svg = render(&pool, id); + assert!(svg.contains("` straddling the glyph outline). +#[test] +fn exports_text_with_a_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, // large font + thin stroke so the fill shows inside the glyph stems + skia::Color::from_rgb(0xE1, 0x7F, 0xDA), // pink fill + Some((StrokeKind::Center, 2.0, skia::Color::from_rgb(0x00, 0x00, 0xFF))), // blue stroke + ); + + let svg = render(&pool, id); + assert!(svg.contains("` that darkens the shape +/// interior (blur+offset occluder, `operator=\"out\"`, then clipped back in). +#[test] +fn exports_a_shape_with_an_inner_shadow() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 120.0, 90.0), + skia::Color::from_rgb(0xB1, 0xB2, 0xB5), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(0x66, 0x00, 0x00, 0x00), // #000000 @ ~0.4 + 12.0, // blur + 0.0, // spread + (8.0, 6.0), // offset (x, y) + ShadowStyle::Inner, + false, // hidden + )); + } + + let svg = render(&pool, id); + // The inner shadow must survive as a native SVG filter, not a dropped + // `save_layer`, and must be clipped to the shape interior. + assert!( + svg.contains("filter=\"url(#inshadow"), + "inner shadow must be emitted as an SVG filter: {svg}" + ); + assert!( + svg.contains("operator=\"out\"") && svg.contains("flood-color=\"#000000\""), + "inner shadow must build a tinted interior band: {svg}" + ); + insta::assert_snapshot!(svg); +} + +/// Text with an *inner* shadow: the shared renderer paints it via an +/// image-filter overlay dropped by `SkSVGDevice`, so the compositor re-emits +/// it over the glyph silhouette. +#[test] +fn exports_text_with_an_inner_shadow() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, + skia::Color::from_rgb(0xEE, 0x19, 0x19), // red fill + None, + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(0x99, 0x00, 0x00, 0x00), + 6.0, + 0.0, + (4.0, 4.0), + ShadowStyle::Inner, + false, + )); + } + + let svg = render(&pool, id); + assert!(svg.contains(" + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap new file mode 100644 index 0000000000..fd1a2b0dd0 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, group_id)" +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group.snap new file mode 100644 index 0000000000..0de4e2642e --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, group_id)" +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group_whose_mask_is_a_group.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group_whose_mask_is_a_group.snap new file mode 100644 index 0000000000..54466e8c08 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_masked_group_whose_mask_is_a_group.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_center_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_center_stroke.snap new file mode 100644 index 0000000000..2c41d3be88 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_center_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, id)" +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_inner_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_inner_stroke.snap new file mode 100644 index 0000000000..b49e4e93d7 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_inner_stroke.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, id)" +--- + + + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_outer_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_outer_stroke.snap new file mode 100644 index 0000000000..f24c127ee4 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_path_with_a_solid_outer_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_center_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_center_stroke.snap new file mode 100644 index 0000000000..2db883193a --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_center_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, id)" +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_image_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_image_stroke.snap new file mode 100644 index 0000000000..47bcf27f7e --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_image_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 569 +expression: svg +--- + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_stroke.snap new file mode 100644 index 0000000000..d116c18759 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_inner_stroke.snap @@ -0,0 +1,12 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_image_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_image_stroke.snap new file mode 100644 index 0000000000..f4e8797a1c --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_image_stroke.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 602 +expression: svg +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_stroke.snap new file mode 100644 index 0000000000..7e05abf19f --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_dotted_outer_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_drop_shadow.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_drop_shadow.snap new file mode 100644 index 0000000000..2ee51948fa --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_drop_shadow.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_layer_blur.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_layer_blur.snap new file mode 100644 index 0000000000..e9a77aa1ca --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_layer_blur.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_image_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_image_stroke.snap new file mode 100644 index 0000000000..debd56b30e --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_image_stroke.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 535 +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_stroke.snap new file mode 100644 index 0000000000..e707dba666 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_center_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_image_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_image_stroke.snap new file mode 100644 index 0000000000..6e8e06b508 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_image_stroke.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 634 +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_stroke.snap new file mode 100644 index 0000000000..ef73e9a460 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_inner_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..ab24117206 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_image_stroke.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 700 +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_stroke.snap new file mode 100644 index 0000000000..ef033e0600 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_a_solid_outer_stroke.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_image_fill.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_image_fill.snap new file mode 100644 index 0000000000..a57ebe6fcc --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_image_fill.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 849 +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_inner_shadow.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_inner_shadow.snap new file mode 100644 index 0000000000..5aa2340b42 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_shape_with_an_inner_shadow.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap new file mode 100644 index 0000000000..56ee3d8ece --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/mod.rs +expression: "render(&pool, id)" +--- + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap new file mode 100644 index 0000000000..d3652e2c2e --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_center_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_center_stroke.snap new file mode 100644 index 0000000000..7e4caeae22 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_center_stroke.snap @@ -0,0 +1,15 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 1106 +expression: svg +--- + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_inner_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_inner_stroke.snap new file mode 100644 index 0000000000..84c6a9b060 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_inner_stroke.snap @@ -0,0 +1,20 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 1081 +expression: svg +--- + + + + HOLA + + + + HOLA + + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_outer_stroke.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_outer_stroke.snap new file mode 100644 index 0000000000..9b7bd29248 --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_a_solid_outer_stroke.snap @@ -0,0 +1,21 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 1136 +expression: svg +--- + + + + + HOLA + + + + HOLA + + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_an_inner_shadow.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_an_inner_shadow.snap new file mode 100644 index 0000000000..c10d8db37c --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__exports_text_with_an_inner_shadow.snap @@ -0,0 +1,15 @@ +--- +source: src/render/svg/tests/mod.rs +assertion_line: 1214 +expression: svg +--- + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__frame_drop_shadow_does_not_double_child_shadows.snap b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__frame_drop_shadow_does_not_double_child_shadows.snap new file mode 100644 index 0000000000..f41bbce87e --- /dev/null +++ b/render-wasm/src/render/svg/tests/snapshots/render_wasm__render__svg__tests__frame_drop_shadow_does_not_double_child_shadows.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/mod.rs +expression: svg +--- + + + + + + + + + + diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs new file mode 100644 index 0000000000..526b65b976 --- /dev/null +++ b/render-wasm/src/render/svg/text.rs @@ -0,0 +1,136 @@ +use skia_safe::Canvas; + +use crate::error::Result; +use crate::shapes::{Shape, StrokeKind}; + +use super::document::SvgLayerCanvas; +use super::masks::push_inverse_glyph_mask; +use crate::render::vector::{ExportState, VectorRenderer, VectorTarget}; + +/// Emits each semi-transparent *center* text stroke as a `` wrapper +/// around the fully-opaque stroke geometry, matching the GPU/PDF opacity-layer +/// result without a `save_layer` (which `SkSVGDevice` would drop). Inner strokes +/// are handled by `render_text_inner_strokes` and outer strokes (any +/// opacity) by `render_text_outer_strokes`. +pub(super) fn render_text_alpha_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, +) -> Result<()> { + let matrix = element.centered_transform(); + for stroke in element.visible_strokes() { + if stroke.render_kind(false) != StrokeKind::Center { + continue; + } + let opacity = stroke.fill.opacity(); + if opacity >= 1.0 { + // Opaque strokes were already drawn by `render_leaf_content`. + continue; + } + + builder.open_group(&format!("opacity=\"{opacity}\"")); + { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_text_stroke_opaque(element, stroke)?; + canvas.restore(); + } + builder.close_group(); + } + Ok(()) +} + +/// Emits each inner text stroke as a `` (glyph-silhouette clip) +/// wrapping the fully-opaque double-width stroke, plus a `` when the +/// stroke is semi-transparent. Reproduces the GPU/PDF mask + `SrcIn` + `DstOver` +/// inner-stroke composition, which `SkSVGDevice` drops (it lives inside +/// `save_layer`s). Inner strokes vanish from SVG regardless of opacity, so all +/// of them are handled here. +pub(super) fn render_text_inner_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, +) -> Result<()> { + let matrix = element.centered_transform(); + for stroke in element.visible_strokes() { + if stroke.render_kind(false) != StrokeKind::Inner { + continue; + } + + // Clip path from the opaque glyph silhouette: clipping the double-width + // stroke to the glyph interior keeps only its inner half. + let clip_id = builder.unique("tclip"); + { + let canvas = builder.new_fragment(); + { + let cv: &Canvas = &*canvas; + cv.concat(&matrix); + let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg); + renderer.draw_text_glyph_silhouette(element)?; + } + builder.finish_clip_path_fragment(&clip_id, canvas); + } + + let opacity = stroke.fill.opacity(); + let mut attrs = format!("clip-path=\"url(#{clip_id})\""); + if opacity < 1.0 { + attrs.push_str(&format!(" opacity=\"{opacity}\"")); + } + builder.open_group(&attrs); + { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_text_stroke_opaque(element, stroke)?; + canvas.restore(); + } + builder.close_group(); + } + Ok(()) +} + +/// Emits each *outer* text stroke as a `` (glyph-exterior mask) wrapping +/// the fully-opaque double-width stroke, plus a `` when the stroke is +/// semi-transparent. The shared renderer draws the outer stroke at double width +/// centered on the glyph outline and relies on a `save_layer` + `Clear` (keep +/// only the outer half) that `SkSVGDevice` drops, so on SVG it is skipped there +/// and re-emitted here masked to the glyph exterior (an inverse-of-glyph +/// luminance mask: white canvas minus the glyphs, since `` cannot +/// subtract), keeping only the outer half and matching the GPU/PDF width. +pub(super) fn render_text_outer_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut ExportState, + element: &Shape, + scale: f32, +) -> Result<()> { + let matrix = element.centered_transform(); + for stroke in element.visible_strokes() { + if stroke.render_kind(false) != StrokeKind::Outer { + continue; + } + + let mask_id = push_inverse_glyph_mask(builder, shared, element, &matrix, scale)?; + + let opacity = stroke.fill.opacity(); + let mut attrs = format!("mask=\"url(#{mask_id})\""); + if opacity < 1.0 { + attrs.push_str(&format!(" opacity=\"{opacity}\"")); + } + builder.open_group(&attrs); + { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg); + renderer.draw_text_stroke_opaque(element, stroke)?; + canvas.restore(); + } + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index d972e69813..81cb7092e5 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -2,11 +2,11 @@ use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT}; use crate::{ error::Result, math::Rect, + render::TextShapingCtx, shapes::{ calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent, }, - utils::{get_fallback_fonts, get_font_collection}, }; use skia_safe::{ self as skia, @@ -17,12 +17,13 @@ use skia_safe::{ pub fn stroke_paragraph_builder_group_from_text( text_content: &TextContent, + ctx: &TextShapingCtx, stroke: &Stroke, bounds: &Rect, use_shadow: Option, ) -> (Vec, Option) { - let fallback_fonts = get_fallback_fonts(); - let fonts = get_font_collection(); + let fallback_fonts = ctx.fallback_fonts(); + let fonts = ctx.font_collection(); let mut paragraph_group = Vec::new(); let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent(); let mut group_layer_opacity: Option = None; @@ -39,7 +40,7 @@ pub fn stroke_paragraph_builder_group_from_text( group_layer_opacity = stroke_layer_opacity; } - let text: String = span.apply_text_transform(); + let text: String = span.apply_text_transform(ctx.browser); for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| { @@ -145,6 +146,7 @@ pub fn render_with_bounds_outset( stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { render_with_bounds_outset_inner( render_state, @@ -158,6 +160,7 @@ pub fn render_with_bounds_outset( fill_inset, layer_opacity, false, + ctx, ) } @@ -172,6 +175,7 @@ pub fn render_with_bounds_outset_overlay_emoji( stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { render_with_bounds_outset_inner( None, @@ -185,6 +189,7 @@ pub fn render_with_bounds_outset_overlay_emoji( fill_inset, layer_opacity, true, + ctx, ) } @@ -201,6 +206,7 @@ fn render_with_bounds_outset_inner( fill_inset: Option, layer_opacity: Option, overlay_emoji: bool, + ctx: &TextShapingCtx, ) -> Result<()> { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -208,7 +214,7 @@ fn render_with_bounds_outset_inner( if let Some(blur_filter) = blur { let mut text_bounds = shape .get_text_content() - .calculate_bounds(shape, false) + .calculate_bounds(ctx, shape, false) .to_rect(); if stroke_bounds_outset > 0.0 { text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); @@ -231,6 +237,7 @@ fn render_with_bounds_outset_inner( fill_inset, layer_opacity, false, + ctx, ); Ok(()) }, @@ -250,6 +257,7 @@ fn render_with_bounds_outset_inner( fill_inset, layer_opacity, false, + ctx, ); return Ok(()); } @@ -264,6 +272,7 @@ fn render_with_bounds_outset_inner( fill_inset, layer_opacity, overlay_emoji, + ctx, ); } Ok(()) @@ -280,6 +289,7 @@ pub fn render( blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { render_with_bounds_outset( render_state, @@ -292,6 +302,7 @@ pub fn render( 0.0, fill_inset, layer_opacity, + ctx, ) } @@ -305,6 +316,7 @@ pub fn render_overlay_emoji( blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { render_with_bounds_outset_overlay_emoji( canvas, @@ -315,6 +327,7 @@ pub fn render_overlay_emoji( 0.0, fill_inset, layer_opacity, + ctx, ) } @@ -328,6 +341,7 @@ fn render_text_on_canvas( fill_inset: Option, layer_opacity: Option, overlay_emoji: bool, + ctx: &TextShapingCtx, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -345,6 +359,7 @@ fn render_text_on_canvas( paragraph_builders, layer_opacity, overlay_emoji, + ctx, ); canvas.restore(); } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { @@ -359,6 +374,7 @@ fn render_text_on_canvas( paragraph_builders, layer_opacity, overlay_emoji, + ctx, ); canvas.restore(); } else { @@ -368,6 +384,7 @@ fn render_text_on_canvas( paragraph_builders, layer_opacity, overlay_emoji, + ctx, ); } } else { @@ -377,14 +394,13 @@ fn render_text_on_canvas( paragraph_builders, layer_opacity, overlay_emoji, + ctx, ); } if blur.is_some() { canvas.restore(); } - - canvas.restore(); } /// Lays out and paints paragraph builders without any layer management. @@ -392,8 +408,9 @@ fn paint_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], + ctx: &TextShapingCtx, ) { - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false, ctx); } fn paint_text_with_emoji_overlay( @@ -401,10 +418,11 @@ fn paint_text_with_emoji_overlay( shape: &Shape, paragraph_builder_groups: &mut [Vec], overlay_emoji: bool, + ctx: &TextShapingCtx, ) { let text_content = shape.get_text_content(); let mut layout_info = - calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); + calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true, ctx); for para in &mut layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); @@ -657,9 +675,11 @@ pub fn render_emoji_overlay( deco_builders: &mut [Vec], surface_id: SurfaceId, blur: Option<&ImageFilter>, + ctx: &TextShapingCtx, ) { let text_content = shape.get_text_content(); - let mut emoji_layout = calculate_text_layout_data(shape, text_content, emoji_builders, true); + let mut emoji_layout = + calculate_text_layout_data(shape, text_content, emoji_builders, true, ctx); if !emoji_layout .paragraphs @@ -681,7 +701,7 @@ pub fn render_emoji_overlay( } } - let deco_layout = calculate_text_layout_data(shape, text_content, deco_builders, true); + let deco_layout = calculate_text_layout_data(shape, text_content, deco_builders, true, ctx); let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); if let Some(blur_filter) = blur { @@ -709,17 +729,26 @@ fn draw_text( paragraph_builder_groups: &mut [Vec], layer_opacity: Option, overlay_emoji: bool, + ctx: &TextShapingCtx, ) { + // Text-level opacity must apply to the union of (possibly overlapping) + // glyphs, so it gets its own layer; the layer is balanced here (self + // contained) instead of relying on a `restore` in the caller. + // + // With no opacity we paint straight to the canvas: an empty isolation + // `save_layer` is a no-op for SrcOver text on the GPU/PDF backends and, on + // Skia's SVG backend, it is silently dropped (making the text vanish). The + // caller/compositor owns shape-level opacity, blend and blur. if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); let layer_rec = SaveLayerRec::default().paint(&opacity_paint); canvas.save_layer(&layer_rec); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji, ctx); + canvas.restore(); } else { - canvas.save_layer(&SaveLayerRec::default()); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji, ctx); } - - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders a text stroke masked to the glyph shape. @@ -740,6 +769,7 @@ fn render_masked_stroke_on_canvas( stroke_mask_blend: skia::BlendMode, blur: Option<&ImageFilter>, layer_opacity: Option, + ctx: &TextShapingCtx, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -757,13 +787,13 @@ fn render_masked_stroke_on_canvas( canvas.save_layer(&SaveLayerRec::default()); - paint_text(canvas, shape, mask_builders); + paint_text(canvas, shape, mask_builders, ctx); let mut stroke_paint = Paint::default(); stroke_paint.set_blend_mode(stroke_mask_blend); canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); - paint_text(canvas, shape, stroke_builders); + paint_text(canvas, shape, stroke_builders, ctx); // Fill with DstOver behind the stroke, inside the masked layer so the fill's // anti-aliased edge aligns with the stroke (no seam at the glyph edge). @@ -773,7 +803,7 @@ fn render_masked_stroke_on_canvas( dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); - paint_text(canvas, shape, fill_builders); + paint_text(canvas, shape, fill_builders, ctx); canvas.restore(); // DstOver layer } @@ -804,6 +834,7 @@ fn render_masked_stroke( blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -811,7 +842,7 @@ fn render_masked_stroke( if let Some(blur_filter) = blur { let mut text_bounds = shape .get_text_content() - .calculate_bounds(shape, false) + .calculate_bounds(ctx, shape, false) .to_rect(); if stroke_bounds_outset > 0.0 { text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); @@ -835,6 +866,7 @@ fn render_masked_stroke( stroke_mask_blend, Some(&blur_filter_clone), layer_opacity, + ctx, ); Ok(()) }, @@ -854,6 +886,7 @@ fn render_masked_stroke( stroke_mask_blend, blur, layer_opacity, + ctx, ); return Ok(()); } @@ -868,6 +901,7 @@ fn render_masked_stroke( stroke_mask_blend, blur, layer_opacity, + ctx, ); } Ok(()) @@ -884,8 +918,9 @@ pub fn render_inner_stroke( blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { - let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(); + let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(ctx); render_masked_stroke( render_state, canvas, @@ -898,6 +933,7 @@ pub fn render_inner_stroke( blur, stroke_bounds_outset, layer_opacity, + ctx, ) } @@ -911,8 +947,9 @@ pub fn render_outer_stroke( blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, + ctx: &TextShapingCtx, ) -> Result<()> { - let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(); + let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(ctx); render_masked_stroke( render_state, canvas, @@ -925,6 +962,7 @@ pub fn render_outer_stroke( blur, stroke_bounds_outset, layer_opacity, + ctx, ) } diff --git a/render-wasm/src/render/text_shaping.rs b/render-wasm/src/render/text_shaping.rs new file mode 100644 index 0000000000..b9068466a9 --- /dev/null +++ b/render-wasm/src/render/text_shaping.rs @@ -0,0 +1,34 @@ +use std::collections::HashSet; + +use skia_safe::textlayout; + +use super::fonts::FontStore; +use crate::utils::Browser; + +/// Font and browser context for text shaping (paragraph builders, transforms). +pub struct TextShapingCtx<'a> { + pub fonts: &'a FontStore, + pub browser: Browser, +} + +impl<'a> TextShapingCtx<'a> { + pub fn new(fonts: &'a FontStore, browser: Browser) -> Self { + Self { fonts, browser } + } + + /// Live editor session: GPU `RenderState` fonts and the design browser. + pub fn from_session() -> TextShapingCtx<'static> { + TextShapingCtx { + fonts: crate::get_render_state().fonts(), + browser: Browser::from(crate::globals::get_design_state().current_browser), + } + } + + pub fn font_collection(&self) -> &textlayout::FontCollection { + self.fonts.font_collection() + } + + pub fn fallback_fonts(&self) -> &HashSet { + self.fonts.get_fallback() + } +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 04d91552d9..7e9fba1a5f 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -9,48 +9,267 @@ use crate::uuid::Uuid; use super::shape_renderer::ShapeRenderer; use super::text; -use super::RenderState; use super::{get_dest_rect, get_source_rect}; +use super::text_shaping::TextShapingCtx; +use super::{FontStore, ImageProvider}; + +use crate::render::svg::{emit_linked_image_fill, emit_linked_image_stroke, SvgLayerCanvas}; // --------------------------------------------------------------------------- -// VectorTarget — vector export backend selector +// ExportState: GPU-free view of the renderer state for vector export // --------------------------------------------------------------------------- -/// Vector export backend selector (PDF today; SVG could be added as a variant). +/// The subset of renderer state the vector export path (SVG/PDF) actually +/// touches. It decouples the exporters from [`super::RenderState`], whose +/// construction requires a live GPU context, so they can run on a plain CPU +/// Skia canvas. +/// +/// In production it is built from a `RenderState` (see `render_to_svg` / +/// `render_to_pdf`); in tests it can be built from a standalone [`FontStore`]. +/// Images are provided through the [`ImageProvider`] trait so image draws are +/// optional: production plugs in the GPU-backed `ImageStore`, headless tests can +/// inject a CPU-only fake, and passing `None` skips image draws entirely. +pub(crate) struct ExportState<'a> { + pub fonts: &'a FontStore, + pub images: Option<&'a mut (dyn ImageProvider + 'a)>, + pub sampling_options: skia::SamplingOptions, +} + +impl<'a> ExportState<'a> { + pub fn text_ctx(&self, browser: crate::utils::Browser) -> TextShapingCtx<'a> { + TextShapingCtx::new(self.fonts, browser) + } +} + +// --------------------------------------------------------------------------- +// VectorTarget: vector export backend selector +// --------------------------------------------------------------------------- + +/// Vector export backend selector. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VectorTarget { Pdf, + Svg, } // --------------------------------------------------------------------------- -// VectorRenderer — implements ShapeRenderer for canvas-based vector export +// VectorCanvas: Skia canvas or SVG layer builder (never both at once) // --------------------------------------------------------------------------- -/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces). -pub(super) struct VectorRenderer<'a> { - canvas: &'a Canvas, - shared: &'a mut RenderState, - scale: f32, - _target: VectorTarget, +/// Where vector drawing happens. [`VectorCanvas::SvgLayer`] owns the fragment +/// canvas inside [`SvgLayerCanvas`]; linked-image export uses the builder +/// directly without also holding a separate canvas borrow. +pub(crate) enum VectorCanvas<'a> { + Borrowed(&'a Canvas), + SvgLayer(&'a mut SvgLayerCanvas), } -impl<'a> VectorRenderer<'a> { - pub fn new( - canvas: &'a Canvas, - shared: &'a mut RenderState, - scale: f32, - target: VectorTarget, - ) -> Self { - Self { - canvas, - shared, - scale, - _target: target, +impl<'a> VectorCanvas<'a> { + pub fn skia_canvas(&mut self) -> &Canvas { + match self { + Self::Borrowed(c) => c, + Self::SvgLayer(b) => b.canvas(), + } + } + + fn svg_layer_mut(&mut self) -> Option<&mut SvgLayerCanvas> { + match self { + Self::Borrowed(_) => None, + Self::SvgLayer(b) => Some(b), } } } -impl ShapeRenderer for VectorRenderer<'_> { +// --------------------------------------------------------------------------- +// VectorRenderer: ShapeRenderer for canvas-based vector export +// --------------------------------------------------------------------------- + +/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces). +pub(super) struct VectorRenderer<'a, 'b> { + canvas: VectorCanvas<'a>, + shared: &'a mut ExportState<'b>, + scale: f32, + target: VectorTarget, +} + +impl<'a, 'b> VectorRenderer<'a, 'b> { + pub fn new( + canvas: &'a Canvas, + shared: &'a mut ExportState<'b>, + scale: f32, + target: VectorTarget, + ) -> Self { + Self { + canvas: VectorCanvas::Borrowed(canvas), + shared, + scale, + target, + } + } + + pub fn new_svg_layer( + builder: &'a mut SvgLayerCanvas, + shared: &'a mut ExportState<'b>, + scale: f32, + target: VectorTarget, + ) -> Self { + Self { + canvas: VectorCanvas::SvgLayer(builder), + shared, + scale, + target, + } + } + + pub fn skia_canvas(&mut self) -> &Canvas { + self.canvas.skia_canvas() + } + + fn text_ctx(&self) -> TextShapingCtx<'b> { + self.shared.text_ctx(crate::utils::Browser::Chrome) + } + + fn is_svg(&self) -> bool { + matches!(self.target, VectorTarget::Svg) + } + + /// Whether the SVG compositor re-emits this stroke (the shared renderer skips + /// it because `SkSVGDevice` drops the `save_layer` composition). + fn defers_stroke_for_svg(shape: &Shape, stroke: &Stroke) -> bool { + if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) + && !shape.is_open() + && stroke.render_kind(false) == StrokeKind::Outer + { + return true; + } + if matches!(shape.shape_type, Type::Rect(_) | Type::Circle) && stroke.clip_op().is_some() { + return true; + } + // Solid/center image strokes only; dotted image strokes still need the + // dropped `save_layer` silhouette before the compositor can mask them. + matches!(stroke.fill, Fill::Image(_)) && stroke.clip_op().is_none() + } + + /// Whether a blur `image_filter` may be attached to a paint. On the SVG + /// compositor, layer blur is emitted as a `` + /// wrapping the shape, and `SkSVGDevice` both ignores paint image filters + /// and drops the implicit layer they trigger (the shape would vanish), so we + /// must not set one there. + fn paint_image_filter(&self, shape: &Shape) -> Option { + if self.is_svg() { + None + } else { + shape.image_filter(1.) + } + } + + /// Renders a single text stroke fully opaque (no opacity layer, no + /// inner-stroke masking), so the SVG compositor can wrap it in a native + /// `` and/or ``. Skia's SVG backend drops the + /// `save_layer`s that the text opacity layer and inner-stroke mask would + /// emit, so those effects are reproduced by the wrapping groups instead. + /// For inner strokes the caller must supply the glyph-silhouette clip + /// (see [`draw_text_glyph_silhouette`]); here the double-width stroke is + /// drawn unclipped and the `` keeps only its inner half. + pub(super) fn draw_text_stroke_opaque(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> { + let Type::Text(text_content) = &shape.shape_type else { + return Ok(()); + }; + + let text_content = text_content.new_bounds(shape.selrect()); + let blur_filter = self.paint_image_filter(shape); + let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); + + // The builders come out opaque (the opacity is meant to live in the + // layer), so rendering with `layer_opacity = None` yields the fully + // opaque stroke that the `` then fades. The exterior clip is + // done by the compositor's ``. + let text_ctx = self.text_ctx(); + let (mut stroke_paragraphs, _) = text::stroke_paragraph_builder_group_from_text( + &text_content, + &text_ctx, + stroke, + &shape.selrect(), + None, + ); + text::render_with_bounds_outset_overlay_emoji( + self.skia_canvas(), + shape, + &mut stroke_paragraphs, + None, + blur_filter.as_ref(), + stroke_blur_outset, + None, + None, + &text_ctx, + )?; + Ok(()) + } + + /// Draws a stroke's opaque black silhouette (its geometry only, no fill), + /// used by the SVG compositor as the source of a `` that confines an + /// image-filled stroke to the stroke region (the vector equivalent of the + /// `save_layer` + `SrcIn` composition used on the GPU/PDF backends). + pub(super) fn draw_stroke_silhouette( + &mut self, + shape: &Shape, + stroke: &Stroke, + inner_clip: bool, + ) -> Result<()> { + let scale = self.scale; + draw_stroke_geometry(self.skia_canvas(), scale, shape, stroke, true, inner_clip); + Ok(()) + } + + /// Draws an image-filled stroke's texture over its destination rect, with no + /// clipping/isolation of its own. The SVG compositor wraps this in a + /// `` (see [`draw_stroke_silhouette`]) so the image only shows in the + /// stroke region; drawing it here without a `save_layer` keeps it out of the + /// layer `SkSVGDevice` would drop. + pub(super) fn draw_stroke_image(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> { + let Fill::Image(image_fill) = &stroke.fill else { + return Ok(()); + }; + let is_svg = self.is_svg(); + let scale = self.scale; + draw_image_stroke( + &mut self.canvas, + self.shared, + scale, + shape, + stroke, + image_fill, + is_svg, + ) + } + + /// Renders the opaque glyph silhouette (the text fill at full alpha), used + /// by the SVG compositor as the geometry of a `` that clips an + /// inner stroke to the glyph interior (the vector equivalent of the + /// mask + `SrcIn` composition used on the GPU/PDF backends). + pub(super) fn draw_text_glyph_silhouette(&mut self, shape: &Shape) -> Result<()> { + let Type::Text(text_content) = &shape.shape_type else { + return Ok(()); + }; + + let text_content = text_content.new_bounds(shape.selrect()); + let text_ctx = self.text_ctx(); + let mut mask_builders = text_content.paragraph_builder_group_opaque(&text_ctx); + text::render_overlay_emoji( + self.skia_canvas(), + shape, + &mut mask_builders, + None, + None, + None, + None, + &text_ctx, + )?; + Ok(()) + } +} + +impl ShapeRenderer for VectorRenderer<'_, '_> { fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> { if fills.is_empty() { return Ok(()); @@ -59,17 +278,24 @@ impl ShapeRenderer for VectorRenderer<'_> { // Handle image fills individually let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); if has_image_fills { + let is_svg = self.is_svg(); for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) => { - draw_image_fill(self.shared, self.canvas, shape, image_fill)?; + draw_image_fill( + self.shared, + &mut self.canvas, + shape, + image_fill, + is_svg, + )?; } _ => { let mut paint = fill.to_paint(&shape.selrect, true); - if let Some(filter) = shape.image_filter(1.) { + if let Some(filter) = self.paint_image_filter(shape) { paint.set_image_filter(filter); } - draw_shape_geometry(self.canvas, shape, &paint); + draw_shape_geometry(self.skia_canvas(), shape, &paint); } } } @@ -79,17 +305,31 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut paint = merge_fills(fills, shape.selrect); paint.set_anti_alias(true); - if let Some(filter) = shape.image_filter(1.) { + if let Some(filter) = self.paint_image_filter(shape) { paint.set_image_filter(filter); } - draw_shape_geometry(self.canvas, shape, &paint); + draw_shape_geometry(self.skia_canvas(), shape, &paint); Ok(()) } fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { + let is_svg = self.is_svg(); + for stroke in strokes.iter().rev() { - draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?; + // SkSVGDevice drops the save_layer composition; the SVG compositor + // re-emits these strokes in `render::svg::{strokes,text}`. + if is_svg && Self::defers_stroke_for_svg(shape, stroke) { + continue; + } + draw_single_stroke( + &mut self.canvas, + self.shared, + self.scale, + shape, + stroke, + is_svg, + )?; } Ok(()) } @@ -100,35 +340,46 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.canvas.save_layer(&layer_rec); + self.skia_canvas().save_layer(&layer_rec); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); - draw_shape_geometry(self.canvas, shape, &fill_paint); - self.canvas.restore(); + draw_shape_geometry(self.skia_canvas(), shape, &fill_paint); + self.skia_canvas().restore(); } } Ok(()) } fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> { + // On SVG the inner-shadow composition lives in a `save_layer` (+ image + // filter) that `SkSVGDevice` drops, so it is re-emitted natively by the + // compositor (`svg::shadows::render_inner_shadows`) as a ``. + if self.is_svg() { + return Ok(()); + } if !shape.has_fills() { return Ok(()); } for shadow in shape.inner_shadows_visible() { let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); - self.canvas + self.skia_canvas() .save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); - draw_shape_geometry(self.canvas, shape, &fill_paint); - self.canvas.restore(); + draw_shape_geometry(self.skia_canvas(), shape, &fill_paint); + self.skia_canvas().restore(); } Ok(()) } fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> { + // SVG: the image-filter draw is wrapped in an implicit layer dropped by + // `SkSVGDevice`. Re-emitted by the compositor (`svg::shadows::render_inner_shadows`). + if self.is_svg() { + return Ok(()); + } let is_open = shape.is_open(); for shadow in shape.inner_shadows_visible() { if let Some(filter) = shadow.get_inner_shadow_filter() { @@ -139,7 +390,7 @@ impl ShapeRenderer for VectorRenderer<'_> { true, ); paint.set_image_filter(filter); - draw_shape_geometry(self.canvas, shape, &paint); + draw_shape_geometry(self.skia_canvas(), shape, &paint); } } Ok(()) @@ -151,15 +402,22 @@ impl ShapeRenderer for VectorRenderer<'_> { }; let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let blur_filter = shape.image_filter(1.); + let text_ctx = self.text_ctx(); + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(&text_ctx, None); + // On the SVG compositor, layer blur is emitted as a `` wrapping + // the whole leaf, so text must not also carry its own blur (which would + // double-blur and, worse, be dropped by `SkSVGDevice`). + let blur_filter = self.paint_image_filter(shape); + let is_svg = self.is_svg(); + let canvas = self.skia_canvas(); // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). let drop_shadows = shape.drop_shadow_paints(); if !drop_shadows.is_empty() { let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); + let mut shadow_paragraphs = + text_content.paragraph_builder_group_from_text(&text_ctx, Some(true)); let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape .visible_strokes() .rev() @@ -168,6 +426,7 @@ impl ShapeRenderer for VectorRenderer<'_> { stroke.render_kind(false), text::stroke_paragraph_builder_group_from_text( &text_content, + &text_ctx, stroke, &shape.selrect(), Some(true), @@ -178,27 +437,27 @@ impl ShapeRenderer for VectorRenderer<'_> { .collect(); for shadow_paint in &drop_shadows { - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); text::render_overlay_emoji( - self.canvas, + canvas, shape, &mut shadow_paragraphs, None, blur_filter.as_ref(), None, None, + &text_ctx, )?; for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { if *kind == StrokeKind::Inner { // Inner stroke masked by the glyph fill (outset 0 here). let mut fill_builders = - text_content.paragraph_builder_group_from_text(Some(true)); + text_content.paragraph_builder_group_from_text(&text_ctx, Some(true)); text::render_inner_stroke( None, - Some(self.canvas), + Some(canvas), shape, stroke_paragraphs, &mut fill_builders, @@ -206,21 +465,23 @@ impl ShapeRenderer for VectorRenderer<'_> { blur_filter.as_ref(), 0.0, None, + &text_ctx, )?; } else if *kind == StrokeKind::Outer { text::render_outer_stroke( None, - Some(self.canvas), + Some(canvas), shape, stroke_paragraphs, None, blur_filter.as_ref(), 0.0, None, + &text_ctx, )?; } else { text::render_with_bounds_outset_overlay_emoji( - self.canvas, + canvas, shape, stroke_paragraphs, None, @@ -228,22 +489,24 @@ impl ShapeRenderer for VectorRenderer<'_> { shadow_stroke_outset, None, None, + &text_ctx, )?; } } - self.canvas.restore(); + canvas.restore(); } } text::render_overlay_emoji( - self.canvas, + canvas, shape, &mut paragraph_builders, None, blur_filter.as_ref(), None, None, + &text_ctx, )?; // Strokes for text @@ -253,16 +516,18 @@ impl ShapeRenderer for VectorRenderer<'_> { let (mut stroke_paragraphs, layer_opacity) = text::stroke_paragraph_builder_group_from_text( &text_content, + &text_ctx, stroke, &shape.selrect(), None, ); if stroke.render_kind(false) == StrokeKind::Inner { // Inner text stroke: clip to the glyph fill, else it bleeds out. - let mut fill_builders = text_content.paragraph_builder_group_from_text(None); + let mut fill_builders = + text_content.paragraph_builder_group_from_text(&text_ctx, None); text::render_inner_stroke( None, - Some(self.canvas), + Some(canvas), shape, &mut stroke_paragraphs, &mut fill_builders, @@ -270,21 +535,35 @@ impl ShapeRenderer for VectorRenderer<'_> { blur_filter.as_ref(), stroke_blur_outset, layer_opacity, + &text_ctx, )?; + } else if is_svg + && (layer_opacity.is_some() + || stroke.render_kind(false) == StrokeKind::Outer) + { + // Re-emitted by the SVG compositor (their composition lives in a + // `save_layer` that `SkSVGDevice` drops): + // - semi-transparent strokes: the opacity layer (would vanish); + // - outer strokes: the double-width stroke must be masked to the + // glyph exterior. Drawn inline it would paint at full double + // width (twice the intended outer width, bleeding over the + // fill), so the compositor masks it to keep only the outer half. + continue; } else if stroke.render_kind(false) == StrokeKind::Outer { text::render_outer_stroke( None, - Some(self.canvas), + Some(canvas), shape, &mut stroke_paragraphs, None, blur_filter.as_ref(), stroke_blur_outset, layer_opacity, + &text_ctx, )?; } else { text::render_with_bounds_outset_overlay_emoji( - self.canvas, + canvas, shape, &mut stroke_paragraphs, None, @@ -292,24 +571,33 @@ impl ShapeRenderer for VectorRenderer<'_> { stroke_blur_outset, None, layer_opacity, + &text_ctx, )?; } } - // Inner shadows for text - let inner_shadows: Vec<_> = shape.inner_shadows_visible().collect(); + // Inner shadows for text. On SVG the shadow paint's image filter forces + // an implicit layer that `SkSVGDevice` drops, so the compositor re-emits + // them natively over the glyph silhouette (`svg::shadows::render_inner_shadows`). + let inner_shadows: Vec<_> = if is_svg { + Vec::new() + } else { + shape.inner_shadows_visible().collect() + }; if !inner_shadows.is_empty() { - let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); + let mut shadow_paragraphs = + text_content.paragraph_builder_group_from_text(&text_ctx, Some(true)); for shadow in &inner_shadows { let shadow_paint = shadow.get_inner_shadow_paint(true, blur_filter.as_ref()); text::render_overlay_emoji( - self.canvas, + canvas, shape, &mut shadow_paragraphs, Some(&shadow_paint), blur_filter.as_ref(), None, None, + &text_ctx, )?; } } @@ -322,15 +610,18 @@ impl ShapeRenderer for VectorRenderer<'_> { return Ok(()); }; + let font_provider = self.shared.fonts.font_provider().clone(); + let canvas = self.skia_canvas(); + if let Some(svg_transform) = shape.svg_transform() { - self.canvas.concat(&svg_transform); + canvas.concat(&svg_transform); } if let Some(svg) = shape.svg.as_ref() { - svg.render(self.canvas); + svg.render(canvas); } else { - let font_manager = skia::FontMgr::from(self.shared.fonts.font_provider().clone()); + let font_manager = skia::FontMgr::from(font_provider); if let Ok(dom) = skia::svg::Dom::from_str(&sr.content, font_manager) { - dom.render(self.canvas); + dom.render(canvas); } } @@ -348,7 +639,7 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.canvas.save_layer(&layer_rec); + self.skia_canvas().save_layer(&layer_rec); true } else { false @@ -356,311 +647,38 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn restore_blur_layer(&mut self) { - self.canvas.restore(); + self.skia_canvas().restore(); } } // --------------------------------------------------------------------------- -// Tree traversal +// Child paint order // --------------------------------------------------------------------------- -/// Depth-first render of the shape tree rooted at `id`. -pub(super) fn render_tree( - shared: &mut RenderState, - canvas: &Canvas, - id: &Uuid, - tree: ShapesPoolRef, - scale: f32, - target: VectorTarget, -) -> Result<()> { - let Some(element) = tree.get(id) else { - return Ok(()); - }; - - if element.hidden { - return Ok(()); - } - - match &element.shape_type { - Type::Group(group) => { - render_group(shared, canvas, element, group.masked, tree, scale, target)?; - } - Type::Frame(_) => { - render_frame(shared, canvas, element, tree, scale, target)?; - } - // Leaf types listed explicitly (no `_`) so a new Type must be handled. - Type::Rect(_) - | Type::Circle - | Type::Path(_) - | Type::Bool(_) - | Type::Text(_) - | Type::SVGRaw(_) => { - render_leaf(shared, canvas, element, scale, target)?; - } - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Groups -// --------------------------------------------------------------------------- - -fn render_group( - shared: &mut RenderState, - canvas: &Canvas, - element: &Shape, - masked: bool, - tree: ShapesPoolRef, - scale: f32, - target: VectorTarget, -) -> Result<()> { - // A group has no geometry of its own and does NOT propagate a transform to - // its children: child shapes are stored in absolute coordinates and each - // applies its own `centered_transform`. (Concatenating the group transform - // here would double-apply it to children — visible on rotated/nested groups.) - canvas.save(); - - // Group drop shadow: subtree silhouette, below the opacity/clip layer. - render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?; - - // Layer for opacity / blend mode (and group-level layer blur) - let needs_layer = element.needs_layer(); - if needs_layer { - let mut paint = Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); - - if let Some(blur) = element - .blur - .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) - { - let sigma = radius_to_sigma(blur.value * scale); - if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { - paint.set_image_filter(filter); - } - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - canvas.save_layer(&layer_rec); - } - - let children: Vec = element.children_ids_iter_forward(false).copied().collect(); - - if masked { - // Mirror the GPU mask: render all children (including the mask shape) - // as content, then re-draw the mask silhouette (the group's first child) - // with DstIn to clip everything to it. - let paint = Paint::default(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); - - for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; - } - - if let Some(mask_id) = element.mask_id() { - let mut mask_paint = Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); - render_tree(shared, canvas, mask_id, tree, scale, target)?; - canvas.restore(); // mask layer - } - - canvas.restore(); // composition layer - } else { - for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; - } - } - - if needs_layer { - canvas.restore(); // opacity/blend layer - } - canvas.restore(); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Frames -// --------------------------------------------------------------------------- - -fn render_frame( - shared: &mut RenderState, - canvas: &Canvas, - element: &Shape, - tree: ShapesPoolRef, - scale: f32, - target: VectorTarget, -) -> Result<()> { - // A frame's own geometry (background, clip, strokes) is placed by its - // `centered_transform`, but — like groups — it does NOT propagate that - // transform to its children, which are stored in absolute coordinates. So - // the transform is applied only around the frame's own draws; children are - // rendered untransformed. - let matrix = element.centered_transform(); - - canvas.save(); - - // Frame drop shadow: background + subtree silhouette, below the clip layer - // so it extends outside the frame bounds. - render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?; - - let needs_layer = element.needs_layer(); - - if needs_layer { - let mut paint = Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); - - // Frame-level layer blur - if let Some(blur) = element - .blur - .filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0) - { - let sigma = radius_to_sigma(blur.value * scale); - if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { - paint.set_image_filter(filter); - } - } - - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - canvas.save_layer(&layer_rec); - } - - // Clip to frame bounds in the frame's own space, then undo the transform so - // children draw at their absolute coords while staying clipped (mirrors the - // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam. - if element.clip_content { - canvas.concat(&matrix); - clip_to_frame_content(canvas, element, scale); - if let Some(inverse) = matrix.invert() { - canvas.concat(&inverse); - } - } - - // Frame's own fills (background) + inner shadows, in the frame's space. - if !element.fills.is_empty() { - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); - renderer.draw_fills(element, &element.fills)?; - renderer.draw_fill_inner_shadows(element)?; - canvas.restore(); - } - - // Children (absolute coords, no frame transform). - let children: Vec = element.children_ids_iter_forward(false).copied().collect(); - for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; - } - - // Strokes over children (clipped frames), in the frame's space. - let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); - if !visible_strokes.is_empty() { - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); - renderer.draw_strokes(element, &visible_strokes)?; - canvas.restore(); - } - - if needs_layer { - canvas.restore(); // opacity/blend layer - } - canvas.restore(); - Ok(()) -} - -/// Drop shadows for a container: render the subtree into a drop-shadow filter -/// layer (its alpha becomes the shadow). `draw_fills` includes the frame -/// background in the silhouette. -fn render_container_drop_shadows( - shared: &mut RenderState, - canvas: &Canvas, - element: &Shape, - tree: ShapesPoolRef, - scale: f32, - target: VectorTarget, - draw_fills: bool, -) -> Result<()> { - for shadow in element.drop_shadows_visible() { - let Some(filter) = shadow.get_drop_shadow_filter() else { - continue; - }; - let mut paint = Paint::default(); - paint.set_image_filter(filter); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); - - if draw_fills && !element.fills.is_empty() { - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); - renderer.draw_fills(element, &element.fills)?; - } - - let children: Vec = element.children_ids_iter_forward(false).copied().collect(); - for child_id in &children { - render_tree(shared, canvas, child_id, tree, scale, target)?; - } - - canvas.restore(); - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) -// --------------------------------------------------------------------------- - -fn render_leaf( - shared: &mut RenderState, - canvas: &Canvas, - element: &Shape, - scale: f32, - target: VectorTarget, -) -> Result<()> { - let needs_layer = element.needs_layer(); - - let matrix = element.centered_transform(); - - canvas.save(); - canvas.concat(&matrix); - - // Layer for opacity/blend - if needs_layer { - let mut paint = Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - canvas.save_layer(&layer_rec); - } - - let mut renderer = VectorRenderer::new(canvas, shared, scale, target); - - // Layer blur (non-text shapes) - let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { - renderer.apply_blur_layer(element) - } else { - false - }; - - renderer.draw_drop_shadows(element)?; - render_leaf_content(&mut renderer, element)?; - - if blur_layer { - renderer.restore_blur_layer(); - } - - if needs_layer { - canvas.restore(); - } - - canvas.restore(); - Ok(()) +/// Children of `element` in the exact paint order (bottom-to-top) the GPU +/// renderer uses, so the vector export matches on-canvas z-order. +/// +/// The GPU pushes `sort_z_index(children_ids_iter(false))` onto a LIFO stack and +/// therefore paints nodes in the reverse of that list. Crucially, `sort_z_index` +/// reorders children by their layout `z-index` for layout containers (flex/grid), +/// which is what places absolutely-positioned items (e.g. a background gradient +/// with a lower z-index) behind the flow content. Iterating children in plain +/// stored order ignored this and drew such items on top. We replicate the GPU +/// ordering here. +pub(super) fn children_paint_order(tree: ShapesPoolRef, element: &Shape) -> Vec { + let ids: Vec = element.children_ids_iter(false).copied().collect(); + let mut ids = super::sort_z_index(tree, element, ids); + ids.reverse(); + ids } /// Single source of truth for leaf content draw order/gating (fills, inner /// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer -/// blur are excluded — they wrap the content and are sequenced per backend. -fn render_leaf_content(renderer: &mut R, shape: &Shape) -> Result<()> { +/// blur are excluded; they wrap the content and are sequenced per backend. +pub(super) fn render_leaf_content( + renderer: &mut R, + shape: &Shape, +) -> Result<()> { match &shape.shape_type { Type::Text(_) => renderer.draw_text(shape)?, Type::SVGRaw(_) => renderer.draw_svg(shape)?, @@ -695,14 +713,33 @@ fn render_leaf_content(renderer: &mut R, shape: &Shap // --------------------------------------------------------------------------- fn draw_image_fill( - shared: &mut RenderState, - canvas: &Canvas, + shared: &mut ExportState, + canvas: &mut VectorCanvas, shape: &Shape, image_fill: &crate::shapes::ImageFill, + is_svg: bool, ) -> Result<()> { - // Use a CPU-backed image copy — GPU-backed images can't be drawn - // on the PDF canvas which has no GPU context. - let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else { + let sampling_options = shared.sampling_options; + + if is_svg { + let Some(builder) = canvas.svg_layer_mut() else { + return Ok(()); + }; + let Some(url) = shared + .images + .as_ref() + .and_then(|images| images.source_url(&image_fill.id())) + else { + return Ok(()); + }; + return emit_linked_image_fill(builder, shape, image_fill, url, shape.selrect); + } + + let Some(images) = shared.images.as_deref_mut() else { + return Ok(()); + }; + + let Some(image) = images.get_cpu_image(&image_fill.id()) else { return Ok(()); }; @@ -712,49 +749,72 @@ fn draw_image_fill( let src_rect = get_source_rect(size, container, image_fill); let dest_rect = container; - canvas.save(); + let skia = canvas.skia_canvas(); + skia.save(); // Clip to shape - clip_to_shape(canvas, shape, true); + clip_to_shape(skia, shape, true); let mut paint = Paint::default(); paint.set_anti_alias(true); - if let Some(filter) = shape.image_filter(1.) { - paint.set_image_filter(filter); + if !is_svg { + if let Some(filter) = shape.image_filter(1.) { + paint.set_image_filter(filter); + } } - canvas.draw_image_rect_with_sampling_options( + skia.draw_image_rect_with_sampling_options( &image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - shared.sampling_options, + sampling_options, &paint, ); - canvas.restore(); + skia.restore(); Ok(()) } fn draw_single_stroke( - canvas: &Canvas, - shared: &mut RenderState, + canvas: &mut VectorCanvas, + shared: &mut ExportState, scale: f32, shape: &Shape, stroke: &Stroke, + is_svg: bool, ) -> Result<()> { // Image-fill strokes: the stroke masks the visible area of the image. if let Fill::Image(image_fill) = &stroke.fill { - return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill); + return draw_image_stroke( + canvas, + shared, + scale, + shape, + stroke, + image_fill, + is_svg, + ); } - draw_stroke_geometry(canvas, scale, shape, stroke, false); + draw_stroke_geometry(canvas.skia_canvas(), scale, shape, stroke, false, true); Ok(()) } /// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle /// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width /// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette. -fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Stroke, opaque: bool) { +/// `inner_clip` applies the shape-interior clip for inner strokes; callers +/// building SVG `` fragments for rotated paths should pass `false` and +/// supply a rotated `` instead (`SkSVGDevice` drops the canvas CTM +/// from canvas clips when serializing mask defs). +fn draw_stroke_geometry( + canvas: &Canvas, + scale: f32, + shape: &Shape, + stroke: &Stroke, + opaque: bool, + inner_clip: bool, +) { let svg_attrs = shape.svg_attrs.as_ref(); let is_open = shape.is_open(); @@ -801,7 +861,7 @@ fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Str paint.set_shader(None); paint.set_color(skia::Color::BLACK); } - draw_stroke_kind_aware(canvas, shape, stroke, &paint); + draw_stroke_kind_aware(canvas, shape, stroke, &paint, inner_clip); if is_open { if let Some(cap_path) = transformed_skia_path(shape) { @@ -818,13 +878,23 @@ fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Str /// Draws a stroked `paint` honoring the stroke kind (inner clip / outer /// layer+clear / center). -fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint: &Paint) { +fn draw_stroke_kind_aware( + canvas: &Canvas, + shape: &Shape, + stroke: &Stroke, + paint: &Paint, + inner_clip: bool, +) { match stroke.render_kind(shape.is_open()) { StrokeKind::Inner => { - canvas.save(); - clip_to_shape(canvas, shape, true); - draw_shape_geometry(canvas, shape, paint); - canvas.restore(); + if inner_clip { + canvas.save(); + clip_to_shape(canvas, shape, true); + draw_shape_geometry(canvas, shape, paint); + canvas.restore(); + } else { + draw_shape_geometry(canvas, shape, paint); + } } StrokeKind::Outer => { canvas.save(); @@ -847,24 +917,48 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint /// Image-filled stroke: draw the stroke silhouette in a layer, then paint the /// CPU image over it with `SrcIn` so only the stroke area shows the image. fn draw_image_stroke( - canvas: &Canvas, - shared: &mut RenderState, + canvas: &mut VectorCanvas, + shared: &mut ExportState, scale: f32, shape: &Shape, stroke: &Stroke, image_fill: &crate::shapes::ImageFill, + is_svg: bool, ) -> Result<()> { - let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else { + let sampling_options = shared.sampling_options; + + let container = shape.selrect; + let dest_rect = get_dest_rect(&container, stroke.delta()); + + if is_svg { + let Some(builder) = canvas.svg_layer_mut() else { + return Ok(()); + }; + let Some(url) = shared + .images + .as_ref() + .and_then(|images| images.source_url(&image_fill.id())) + else { + return Ok(()); + }; + return emit_linked_image_stroke(builder, shape, image_fill, url, dest_rect); + } + + let Some(images) = shared.images.as_deref_mut() else { + return Ok(()); + }; + + let Some(image) = images.get_cpu_image(&image_fill.id()) else { return Ok(()); }; let size = image.dimensions(); - let container = shape.selrect; - canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let skia = canvas.skia_canvas(); + skia.save(); + skia.save_layer(&skia::canvas::SaveLayerRec::default()); // Opaque stroke silhouette; the SrcIn image draw below fills it. - draw_stroke_geometry(canvas, scale, shape, stroke, true); + draw_stroke_geometry(skia, scale, shape, stroke, true, true); let mut image_paint = Paint::default(); image_paint.set_blend_mode(skia::BlendMode::SrcIn); @@ -875,16 +969,16 @@ fn draw_image_stroke( let src_rect = get_source_rect(size, &container, image_fill); let dest_rect = get_dest_rect(&container, stroke.delta()); - canvas.draw_image_rect_with_sampling_options( + skia.draw_image_rect_with_sampling_options( &image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - shared.sampling_options, + sampling_options, &image_paint, ); - canvas.restore(); // layer - canvas.restore(); + skia.restore(); // layer + skia.restore(); Ok(()) } @@ -900,7 +994,7 @@ fn transformed_skia_path(shape: &Shape) -> Option { // --------------------------------------------------------------------------- /// Draws the shape's geometry (rect/rrect/oval/path) with the given paint. -fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { +pub(super) fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { if let Some(corners) = shape.shape_type.corners() { @@ -925,7 +1019,7 @@ fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { /// Clips the canvas to a frame's content bounds, outset by ~0.5 device px so /// the hard (non-AA) clip edge doesn't shave off edge pixels and leave a seam. -fn clip_to_frame_content(canvas: &Canvas, shape: &Shape, scale: f32) { +pub(super) fn clip_to_frame_content(canvas: &Canvas, shape: &Shape, scale: f32) { let outset = 0.5 / scale.max(1e-6); let mut rect = shape.selrect; rect.outset((outset, outset)); @@ -973,3 +1067,4 @@ fn clip_to_shape(canvas: &Canvas, shape: &Shape, antialias: bool) { } } } + diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 92d49c7c30..60af356b88 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -397,7 +397,8 @@ impl Shape { self.invalidate_extrect(); self.selrect.set_ltrb(left, top, right, bottom); if let Type::Text(ref mut text) = self.shape_type { - text.update_layout(self.selrect); + let ctx = crate::render::TextShapingCtx::from_session(); + text.update_layout(&ctx, self.selrect); text.set_xywh(left, top, self.selrect.width(), self.selrect.height()); } } @@ -791,12 +792,22 @@ impl Shape { self.selrect.width() } - pub fn extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { - self.calculate_extrect(shapes_pool, scale) + pub fn extrect( + &self, + shapes_pool: ShapesPoolRef, + scale: f32, + ctx: &crate::render::TextShapingCtx, + ) -> math::Rect { + self.calculate_extrect(shapes_pool, scale, ctx) } - pub fn visually_insignificant(&self, scale: f32, shapes_pool: ShapesPoolRef) -> bool { - let extrect = self.extrect(shapes_pool, scale); + pub fn visually_insignificant( + &self, + scale: f32, + shapes_pool: ShapesPoolRef, + ctx: &crate::render::TextShapingCtx, + ) -> bool { + let extrect = self.extrect(shapes_pool, scale, ctx); extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE } @@ -953,6 +964,7 @@ impl Shape { bounds: Bounds, shapes_pool: ShapesPoolRef, scale: f32, + ctx: &crate::render::TextShapingCtx, ) -> Bounds { let mut rect = bounds.to_rect(); @@ -963,7 +975,7 @@ impl Shape { for (index, child_id) in self.children.iter().enumerate() { if let Some(child_shape) = shapes_pool.get(child_id) { - let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); + let child_extrect = child_shape.calculate_extrect(shapes_pool, scale, ctx); if index == 0 { mask_rect = Some(child_extrect); @@ -997,7 +1009,7 @@ impl Shape { for child_id in self.children_ids_iter(false) { if let Some(child_shape) = shapes_pool.get(child_id) { // Always calculate full extrect for children to ensure accurate bounds - let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); + let child_extrect = child_shape.calculate_extrect(shapes_pool, scale, ctx); rect.join(child_extrect); } } @@ -1046,19 +1058,29 @@ impl Shape { Bounds::from_rect(&rect) } - pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + pub fn calculate_extrect( + &self, + shapes_pool: ShapesPoolRef, + scale: f32, + ctx: &crate::render::TextShapingCtx, + ) -> math::Rect { // `scale` is forwarded to children but intentionally NOT part of the cache key. if let Some(cached_extrect) = *self.extrect_cache.borrow() { return cached_extrect; } - let extrect = self.calculate_extrect_uncached(shapes_pool, scale); + let extrect = self.calculate_extrect_uncached(shapes_pool, scale, ctx); *self.extrect_cache.borrow_mut() = Some(extrect); extrect } - fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + fn calculate_extrect_uncached( + &self, + shapes_pool: ShapesPoolRef, + scale: f32, + ctx: &crate::render::TextShapingCtx, + ) -> math::Rect { let shape = self; let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); @@ -1076,7 +1098,7 @@ impl Shape { } Type::Text(text_content) => { // FIXME: we need to recalculate the text bounds here because the shape's selrect - text_content.calculate_bounds(shape, false) + text_content.calculate_bounds(ctx, shape, false) } _ => shape.calculate_bounds(false), }; @@ -1084,7 +1106,7 @@ impl Shape { bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_blur_bounds(bounds); - bounds = self.apply_children_bounds(bounds, shapes_pool, scale); + bounds = self.apply_children_bounds(bounds, shapes_pool, scale, ctx); bounds = self.apply_children_blur(bounds, shapes_pool); if !self.transform.is_identity() { @@ -1441,7 +1463,8 @@ impl Shape { // frame bounds, a cached crop anchored to the frame can easily become incorrect while // moving (children can extend beyond selrect). Be conservative and render live. if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content { - let extrect = self.extrect(shapes_pool, 1.0); + let text_ctx = crate::render::TextShapingCtx::from_session(); + let extrect = self.extrect(shapes_pool, 1.0, &text_ctx); let sr = self.selrect; let exceeds = extrect.left < sr.left || extrect.top < sr.top @@ -1950,7 +1973,10 @@ mod tests { } let group = pool.get(&group_id).expect("group should exist"); - let extrect = group.calculate_extrect(&pool, 1.0); + let fonts = crate::render::FontStore::try_new().expect("fonts"); + let text_ctx = + crate::render::TextShapingCtx::new(&fonts, crate::utils::Browser::Chrome); + let extrect = group.calculate_extrect(&pool, 1.0, &text_ctx); assert_eq!(extrect.left, 0.0); assert_eq!(extrect.top, 0.0); diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 87da3881bf..606c81c214 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -126,6 +126,14 @@ impl ImageFill { pub fn keep_aspect_ratio(&self) -> bool { self.keep_aspect_ratio } + + pub fn width(&self) -> i32 { + self.width + } + + pub fn height(&self) -> i32 { + self.height + } } #[derive(Debug, Clone, PartialEq, Copy)] diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 109fc605b2..a0d3144a7f 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -212,9 +212,10 @@ fn propagate_transform( match text_content.grow_type() { GrowType::AutoHeight => { let height_before = text_content.size.height; + let text_ctx = crate::render::TextShapingCtx::from_session(); let new_height = if width_changed { let mut clone = text_content.clone(); - clone.update_layout(resized_selrect); + clone.update_layout(&text_ctx, resized_selrect); clone.size.height } else { height_before @@ -242,9 +243,10 @@ fn propagate_transform( GrowType::AutoWidth => { let width_before = text_content.width(); let height_before = text_content.size.height; + let text_ctx = crate::render::TextShapingCtx::from_session(); let (new_width, new_height) = if height_changed { let mut clone = text_content.clone(); - clone.update_layout(resized_selrect); + clone.update_layout(&text_ctx, resized_selrect); (clone.width(), clone.size.height) } else { (width_before, height_before) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1ba1381172..9fcb33a3f1 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -22,9 +22,9 @@ use std::cell::Cell; use std::collections::HashSet; use super::FontFamily; +use crate::render::TextShapingCtx; use crate::math::Point; use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; -use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? @@ -493,6 +493,7 @@ impl TextContent { fn compute_and_cache_extrect( &self, + ctx: &TextShapingCtx, shape: &Shape, selrect: &Rect, valign: VerticalAlign, @@ -501,17 +502,17 @@ impl TextContent { // (line.left) reflect alignment within that huge width and are // unusable for tight bounds. Fall back to content_rect. if self.grow_type() == GrowType::AutoWidth { - return self.content_rect(selrect, valign); + return self.content_rect(ctx, selrect, valign); } let tight = if !self.layout.paragraphs.is_empty() { self.rect_from_paragraphs(selrect, valign) } else { let mut text_content = self.clone(); - text_content.update_layout(shape.selrect); + text_content.update_layout(ctx, shape.selrect); text_content.rect_from_paragraphs(selrect, valign) } - .unwrap_or_else(|| self.content_rect(selrect, valign)); + .unwrap_or_else(|| self.content_rect(ctx, selrect, valign)); // Cache as offsets from selrect origin so it's position-independent. let sx = selrect.x(); @@ -529,7 +530,12 @@ impl TextContent { tight } - pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds { + pub fn calculate_bounds( + &self, + ctx: &TextShapingCtx, + shape: &Shape, + apply_transform: bool, + ) -> Bounds { let transform = &shape.transform; let center = &shape.center(); let selrect = shape.selrect(); @@ -552,10 +558,10 @@ impl TextContent { sy + cached.bottom, ) } else { - self.compute_and_cache_extrect(shape, &selrect, valign) + self.compute_and_cache_extrect(ctx, shape, &selrect, valign) } } else { - self.compute_and_cache_extrect(shape, &selrect, valign) + self.compute_and_cache_extrect(ctx, shape, &selrect, valign) }; let mut bounds = Bounds::new( @@ -578,7 +584,7 @@ impl TextContent { bounds } - pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { + pub fn content_rect(&self, ctx: &TextShapingCtx, selrect: &Rect, valign: VerticalAlign) -> Rect { let x = selrect.x(); let mut y = selrect.y(); @@ -589,7 +595,7 @@ impl TextContent { }; let height = if self.size.width.round() != width.round() { - self.get_height(width) + self.get_height(ctx, width) } else { self.size.height }; @@ -712,10 +718,11 @@ impl TextContent { /// this text. pub fn paragraph_builder_group_from_text( &self, + ctx: &TextShapingCtx, use_shadow: Option, ) -> Vec { - let fonts = get_font_collection(); - let fallback_fonts = get_fallback_fonts(); + let fonts = ctx.font_collection(); + let fallback_fonts = ctx.fallback_fonts(); let mut paragraph_group = Vec::new(); for paragraph in self.paragraphs() { @@ -730,7 +737,7 @@ impl TextContent { remove_alpha, paragraph.line_height(), ); - let text: String = span.apply_text_transform(); + let text: String = span.apply_text_transform(ctx.browser); if !text.is_empty() { has_text = true; } @@ -748,9 +755,9 @@ impl TextContent { /// 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 { - let fonts = get_font_collection(); - let fallback_fonts = get_fallback_fonts(); + pub fn paragraph_builder_group_opaque(&self, ctx: &TextShapingCtx) -> Vec { + let fonts = ctx.font_collection(); + let fallback_fonts = ctx.fallback_fonts(); let mut paragraph_group = Vec::new(); for paragraph in self.paragraphs() { @@ -764,7 +771,7 @@ impl TextContent { true, // always opaque paragraph.line_height(), ); - let text: String = span.apply_text_transform(); + let text: String = span.apply_text_transform(ctx.browser); if !text.is_empty() { has_text = true; } @@ -781,8 +788,8 @@ impl TextContent { } /// Performs an Auto Width text layout. - fn text_layout_auto_width(&self) -> TextContentLayoutResult { - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + fn text_layout_auto_width(&self, ctx: &TextShapingCtx) -> TextContentLayoutResult { + let mut paragraph_builders = self.paragraph_builder_group_from_text(ctx, None); let normalized_line_height = calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); @@ -812,9 +819,9 @@ impl TextContent { /// Private function that performs /// Performs an Auto Height text layout. - fn text_layout_auto_height(&self) -> TextContentLayoutResult { + fn text_layout_auto_height(&self, ctx: &TextShapingCtx) -> TextContentLayoutResult { let width = self.width(); - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let mut paragraph_builders = self.paragraph_builder_group_from_text(ctx, None); let normalized_line_height = calculate_normalized_line_height(&mut paragraph_builders, width); @@ -836,9 +843,9 @@ impl TextContent { } /// Performs a Fixed text layout. - fn text_layout_fixed(&self) -> TextContentLayoutResult { + fn text_layout_fixed(&self, ctx: &TextShapingCtx) -> TextContentLayoutResult { let width = self.width(); - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let mut paragraph_builders = self.paragraph_builder_group_from_text(ctx, None); let normalized_line_height = calculate_normalized_line_height(&mut paragraph_builders, width); @@ -868,8 +875,8 @@ impl TextContent { } } - pub fn get_height(&self, width: f32) -> f32 { - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + pub fn get_height(&self, ctx: &TextShapingCtx, width: f32) -> f32 { + let mut paragraph_builders = self.paragraph_builder_group_from_text(ctx, None); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() @@ -900,7 +907,7 @@ impl TextContent { self.layout.cached_extrect.set(None); } - pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + pub fn update_layout(&mut self, ctx: &TextShapingCtx, selrect: Rect) -> TextContentSize { if !self.layout.needs_update() && self.layout_version == self.content_version && self @@ -914,24 +921,24 @@ impl TextContent { match self.grow_type() { GrowType::AutoHeight => { - let result = self.text_layout_auto_height(); + let result = self.text_layout_auto_height(ctx); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::AutoWidth => { - let result = self.text_layout_auto_width(); + let result = self.text_layout_auto_width(ctx); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::Fixed => { - let result = self.text_layout_fixed(); + let result = self.text_layout_fixed(ctx); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } } if self.is_empty() { - let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect); + let (placeholder_width, placeholder_height) = self.placeholder_dimensions(ctx, selrect); self.size.width = placeholder_width; self.size.height = placeholder_height; self.size.max_width = placeholder_width; @@ -971,11 +978,11 @@ impl TextContent { /// shows a caret-sized box that reflects the selected font, size and spacing. /// If that fails we fall back to the previous WASM size or the incoming /// selrect dimensions. - fn placeholder_dimensions(&self, selrect: Rect) -> (f32, f32) { + fn placeholder_dimensions(&self, ctx: &TextShapingCtx, selrect: Rect) -> (f32, f32) { if let Some(paragraph) = self.paragraphs.first() { if let Some(span) = paragraph.children().first() { - let fonts = get_font_collection(); - let fallback_fonts = get_fallback_fonts(); + let fonts = ctx.font_collection(); + let fallback_fonts = ctx.fallback_fonts(); let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); @@ -1025,8 +1032,14 @@ impl TextContent { x_pos >= rect.x() && x_pos <= rect.right() && y_pos >= rect.y() && y_pos <= rect.bottom() } - pub fn intersect_position_in_text(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool { - let rect = self.content_rect(&shape.selrect, shape.vertical_align); + pub fn intersect_position_in_text( + &self, + ctx: &TextShapingCtx, + shape: &Shape, + x_pos: f32, + y_pos: f32, + ) -> bool { + let rect = self.content_rect(ctx, &shape.selrect, shape.vertical_align); let mut matrix = Matrix::new_identity(); let center = shape.center(); let Some(inv_transform) = &shape.transform.invert() else { @@ -1054,7 +1067,7 @@ impl TextContent { ) } else { let width = self.width(); - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let mut paragraph_builders = self.paragraph_builder_group_from_text(ctx, None); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); @@ -1363,9 +1376,8 @@ impl TextSpan { format!("{}", self.font_family) } - pub fn apply_text_transform(&self) -> String { - let browser = crate::with_state!(state, { state.current_browser }); - let text = process_ignored_chars(&self.text, browser); + pub fn apply_text_transform(&self, browser: Browser) -> String { + let text = process_ignored_chars(&self.text, browser as u8); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), @@ -1425,6 +1437,7 @@ pub fn calculate_text_layout_data( text_content: &TextContent, paragraph_builder_groups: &mut [ParagraphBuilderGroup], skip_position_data: bool, + ctx: &TextShapingCtx, ) -> TextLayoutData { let selrect_width = shape.selrect().width(); let text_width = text_content.get_width(selrect_width); @@ -1555,7 +1568,7 @@ pub fn calculate_text_layout_data( let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; let mut cur = 0; for (span_index, span) in text_para.children().iter().enumerate() { - let text: String = span.apply_text_transform(); + let text: String = span.apply_text_transform(ctx.browser); let text_len = text.encode_utf16().count(); span_ranges.push((cur, cur + text_len, span_index)); cur += text_len; @@ -1613,16 +1626,18 @@ pub fn calculate_position_data( shape: &Shape, text_content: &TextContent, skip_position_data: bool, + ctx: &TextShapingCtx, ) -> Vec { let mut text_content = text_content.clone(); - text_content.update_layout(shape.selrect); + text_content.update_layout(ctx, shape.selrect); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(ctx, None); let layout_info = calculate_text_layout_data( shape, &text_content, &mut paragraph_builders, skip_position_data, + ctx, ); layout_info.position_data diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 38cf30226f..2fd94ee340 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,4 +1,5 @@ use crate::get_render_state; +use crate::render::TextShapingCtx; use crate::shapes::text::TextContent; use skia_safe::{ self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob, @@ -18,7 +19,8 @@ impl TextPaths { pub fn get_paths(&self, antialias: bool) -> Vec<(skia::Path, skia::Paint)> { let mut paths = Vec::new(); let mut offset_y = self.bounds.y(); - let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None); + let text_ctx = TextShapingCtx::from_session(); + let mut paragraph_builders = self.0.paragraph_builder_group_from_text(&text_ctx, None); for paragraphs in paragraph_builders.iter_mut() { for paragraph_builder in paragraphs.iter_mut() { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c38b604592..f9d29dd43f 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -1,4 +1,4 @@ -use skia_safe::{self as skia, textlayout::FontCollection, Path, Point}; +use skia_safe::{self as skia, Path, Point}; use std::collections::HashMap; mod shapes_pool; @@ -99,6 +99,10 @@ impl State { crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale) } + pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { + crate::render::svg::render_to_svg(get_render_state(), id, &self.shapes, scale) + } + pub fn start_render_loop(&mut self, timestamp: i32) -> Result { let render_state = get_render_state(); // If zoom changed (e.g. interrupted zoom render followed by pan), the @@ -264,10 +268,6 @@ impl State { get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids) } - pub fn font_collection(&self) -> &FontCollection { - get_render_state().fonts().font_collection() - } - pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> { let shape = self.current_shape()?; let bounds = shape.bounds(); diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 206b91dbb0..2282b50e23 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -1,9 +1,6 @@ use crate::get_render_state; -use crate::skia::textlayout::FontCollection; use crate::skia::Image; use crate::uuid::Uuid; -use crate::with_state; -use std::collections::HashSet; pub fn uuid_from_u32_quartet(a: u32, b: u32, c: u32, d: u32) -> Uuid { let hi: u64 = ((a as u64) << 32) | b as u64; @@ -28,15 +25,6 @@ pub fn get_image(image_id: &Uuid) -> Option<&Image> { get_render_state().images.get(image_id) } -// FIXME: move to a different place ? -pub fn get_fallback_fonts() -> &'static HashSet { - get_render_state().fonts().get_fallback() -} - -pub fn get_font_collection() -> &'static FontCollection { - with_state!(state, { state.font_collection() }) -} - #[derive(Debug, Clone, Copy)] #[repr(u8)] pub enum Browser { diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 65a4697181..5c60f85af7 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -190,3 +190,16 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { mem::free_bytes()?; Ok(()) } + +#[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_render_state().images.set_source_url(id, url); + Ok(()) +} diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 21c4e6a797..3916a78f51 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,5 +1,6 @@ use macros::{wasm_error, ToJs}; +use crate::error::Error; use crate::get_render_state; use crate::mem; use crate::shapes::{FontFamily, FontStyle}; @@ -53,6 +54,32 @@ pub extern "C" fn store_font( Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn store_font_url( + a: u32, + b: u32, + c: u32, + d: u32, + weight: u32, + style: u8, +) -> 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 font source URL".to_string()) + })?; + mem::free_bytes()?; + + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + get_render_state() + .fonts_mut() + .set_source_url(&family.alias(), url); + + Ok(()) +} + #[no_mangle] pub extern "C" fn is_font_uploaded( a: u32, diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 6e2575741f..e080442d00 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -6,6 +6,7 @@ use crate::mem::{self, SerializableResult}; use crate::shapes::{ self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; +use crate::render::TextShapingCtx; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state}; @@ -326,10 +327,11 @@ pub extern "C" fn get_text_dimensions() -> *mut u8 { with_current_shape_mut!(state, |shape: &mut Shape| { if let Type::Text(content) = &mut shape.shape_type { - let text_content_size = content.update_layout(shape.selrect); + let text_ctx = TextShapingCtx::from_session(); + let text_content_size = content.update_layout(&text_ctx, shape.selrect); // Sacar de aqui x, y, width, height - let rect = content.content_rect(&shape.selrect, shape.vertical_align); + let rect = content.content_rect(&text_ctx, &shape.selrect, shape.vertical_align); let mut bytes = vec![0; 20]; bytes[0..4].clone_from_slice(&text_content_size.width.to_le_bytes()); @@ -364,7 +366,8 @@ pub extern "C" fn intersect_position_in_shape( return false; }; if let Type::Text(content) = &shape.shape_type { - return content.intersect_position_in_text(shape, x_pos, y_pos); + let text_ctx = TextShapingCtx::from_session(); + return content.intersect_position_in_text(&text_ctx, shape, x_pos, y_pos); } }); false @@ -372,10 +375,11 @@ pub extern "C" fn intersect_position_in_shape( fn update_text_layout(shape: &mut Shape, force: bool) { if let Type::Text(text_content) = &mut shape.shape_type { + let text_ctx = TextShapingCtx::from_session(); if force { text_content.force_next_layout_update(); } - text_content.update_layout(shape.selrect); + text_content.update_layout(&text_ctx, shape.selrect); shape.invalidate_extrect(); } } @@ -439,7 +443,8 @@ pub extern "C" fn calculate_position_data() -> *mut u8 { let mut result = Vec::::default(); with_current_shape!(state, |shape: &Shape| { if let Type::Text(text_content) = &shape.shape_type { - result = shapes::calculate_position_data(shape, text_content, false); + let text_ctx = TextShapingCtx::from_session(); + result = shapes::calculate_position_data(shape, text_content, false, &text_ctx); } }); mem::write_vec(result) diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 1ee391b40b..40164b1eac 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,6 +1,7 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; +use crate::render::TextShapingCtx; use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::render::text_editor as text_editor_render; @@ -862,7 +863,8 @@ pub extern "C" fn text_editor_render_overlay() { let selrect = shape.selrect(); if let Some(shape) = state.shapes.get_mut(&shape_id) { if let Type::Text(text_content) = &mut shape.shape_type { - text_content.update_layout(selrect); + let text_ctx = TextShapingCtx::from_session(); + text_content.update_layout(&text_ctx, selrect); } } }