mirror of
https://github.com/penpot/penpot.git
synced 2026-08-05 12:29:00 +00:00
✨ Add single-file export to SVG using the WebGL render
This commit is contained in:
parent
a4ba5fc2e0
commit
e54260a7ac
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
70
render-wasm/Cargo.lock
generated
70
render-wasm/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
144
render-wasm/preview-snapshots
Executable file
144
render-wasm/preview-snapshots
Executable file
@ -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 <object> 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 <object> (not <img>) 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' "$file" | sed 's/>.*//')
|
||||
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 "<div class=\"cb\"><object data=\"$name\" type=\"image/svg+xml\" width=\"$SVG_W\" height=\"$SVG_H\"></object></div>"
|
||||
}
|
||||
|
||||
{
|
||||
cat <<'HTML'
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>render-wasm SVG snapshot preview</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; margin: 24px; }
|
||||
h1 { font-size: 20px; }
|
||||
section { border: 1px solid #8884; border-radius: 8px; padding: 12px 16px; margin: 16px 0; }
|
||||
h2 { font-size: 14px; font-family: monospace; margin: 0 0 8px; word-break: break-all; }
|
||||
.cols { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
figure { margin: 0; }
|
||||
figcaption { font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||
/* Checkerboard so transparency / opacity / blend are visible. */
|
||||
.cb {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
border: 1px solid #8884;
|
||||
}
|
||||
.cb object {
|
||||
display: block;
|
||||
}
|
||||
.changed { color: #c60; font-weight: bold; font-size: 13px; }
|
||||
details { margin-top: 8px; }
|
||||
pre { background: #8881; padding: 8px; overflow: auto; font-size: 12px; }
|
||||
</style>
|
||||
<h1>render-wasm SVG snapshot preview</h1>
|
||||
HTML
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
for snap in "$SNAP_DIR"/*.snap; do
|
||||
name="$(basename "$snap" .snap)"
|
||||
new="$snap.new"
|
||||
echo "<section><h2>$name</h2>"
|
||||
if [ -f "$new" ]; then
|
||||
echo '<p class="changed">Pending change: review before accepting</p>'
|
||||
echo '<div class="cols">'
|
||||
echo '<figure><figcaption>accepted (current .snap)</figcaption>'
|
||||
emit_svg_box "$snap"
|
||||
echo '</figure>'
|
||||
echo '<figure><figcaption>new (.snap.new)</figcaption>'
|
||||
emit_svg_box "$new"
|
||||
echo '</figure>'
|
||||
echo '</div>'
|
||||
echo '<details><summary>text diff</summary><pre>'
|
||||
diff -u "$snap" "$new" | sed 's/&/\&/g; s/</\</g; s/>/\>/g' || true
|
||||
echo '</pre></details>'
|
||||
else
|
||||
emit_svg_box "$snap"
|
||||
fi
|
||||
echo '</section>'
|
||||
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 "<section><h2>$name</h2>"
|
||||
echo '<p class="changed">new snapshot (no accepted version yet)</p>'
|
||||
emit_svg_box "$new"
|
||||
echo '</section>'
|
||||
done
|
||||
} > "$OUT"
|
||||
|
||||
echo "Wrote $OUT"
|
||||
|
||||
if [ "${1:-}" = "--open" ]; then
|
||||
xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
@ -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
|
||||
|
||||
@ -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<Corners>, 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<StrokeKind> =
|
||||
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<skia::Rect> = 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<Rect> = 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<Rect> = 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) {
|
||||
|
||||
@ -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<String>,
|
||||
/// Source URL registered when the font was fetched (SVG export references
|
||||
/// this in `@font-face` rules).
|
||||
source_urls: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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<Font> {
|
||||
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>) -> String {
|
||||
let mut seen: HashSet<String> = 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 `<text>` 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 `<text>` 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 `<text>` 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 {
|
||||
|
||||
@ -62,6 +62,31 @@ enum StoredImage {
|
||||
pub struct ImageStore {
|
||||
images: HashMap<(Uuid, bool), StoredImage>,
|
||||
context: Box<DirectContext>,
|
||||
/// Source URL registered when the image was fetched (referenced in SVG export).
|
||||
source_urls: HashMap<Uuid, String>,
|
||||
}
|
||||
|
||||
/// 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<Image>;
|
||||
|
||||
fn source_url(&self, id: &Uuid) -> Option<&str> {
|
||||
let _ = id;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProvider for ImageStore {
|
||||
fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
|
||||
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<Image> {
|
||||
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
|
||||
|
||||
@ -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 `<clipPath>` 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(())
|
||||
}
|
||||
|
||||
@ -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<skia_safe::ImageFilter>,
|
||||
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,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
406
render-wasm/src/render/svg/document.rs
Normal file
406
render-wasm/src/render/svg/document.rs
Normal file
@ -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
|
||||
// `<path>`/`<text>`/… vector markup), and composite effects become native SVG
|
||||
// `<g>` 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<skia::svg::Canvas>,
|
||||
next_id: usize,
|
||||
frag_no: usize,
|
||||
def_cache: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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("<g ");
|
||||
self.out.push_str(attrs);
|
||||
self.out.push('>');
|
||||
}
|
||||
|
||||
pub(super) fn close_group(&mut self) {
|
||||
self.flush();
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
|
||||
pub(super) fn push_def(&mut self, def: &str) {
|
||||
self.defs.push_str(def);
|
||||
}
|
||||
|
||||
/// Interns a reusable `<def>` 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 `<filter>`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 `<clipPath>` from a mask shape's geometry (in device/page space)
|
||||
/// and returns nothing; the caller wraps content in
|
||||
/// `<g clip-path="url(#id)">`.
|
||||
///
|
||||
/// 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 `<mask>` def, returning the def id for the
|
||||
/// caller to reference via `mask="url(#id)"`.
|
||||
///
|
||||
/// Unlike a geometric `<clipPath>`, 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<String> {
|
||||
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!(
|
||||
"<mask id=\"{mask_ref}\" maskUnits=\"userSpaceOnUse\" \
|
||||
mask-type=\"alpha\">{sub_body}</mask>"
|
||||
));
|
||||
Ok(mask_ref)
|
||||
}
|
||||
|
||||
/// Builds an alpha `<mask>` 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<String> {
|
||||
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 `<mask>` 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!(
|
||||
"<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\" \
|
||||
mask-type=\"alpha\">{geometry}</mask>"
|
||||
));
|
||||
}
|
||||
|
||||
/// Finalizes a fragment canvas as a `<clipPath>` 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!(
|
||||
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
|
||||
));
|
||||
}
|
||||
|
||||
/// Finalizes a fragment canvas as a luminance `<mask>` 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!(
|
||||
"<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\">{geometry}</mask>"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 `<g>` attribute string for a shape's composite effects (opacity,
|
||||
/// blend mode, layer blur), registering any needed `<defs>`. Returns `None`
|
||||
/// when the shape needs no wrapper.
|
||||
pub(super) fn effect_attrs(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
element: &Shape,
|
||||
scale: f32,
|
||||
) -> Option<String> {
|
||||
let mut parts: Vec<String> = 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!(
|
||||
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\
|
||||
<feGaussianBlur stdDeviation=\"{sigma}\"/></filter>"
|
||||
));
|
||||
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<f32> {
|
||||
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 `<svg …>` tag and the closing `</svg>`).
|
||||
fn extract_inner_svg(doc: &str) -> &str {
|
||||
let start = doc
|
||||
.find("<svg")
|
||||
.and_then(|s| doc[s..].find('>').map(|e| s + e + 1));
|
||||
let end = doc.rfind("</svg>");
|
||||
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
|
||||
}
|
||||
411
render-wasm/src/render/svg/fixtures.rs
Normal file
411
render-wasm/src/render/svg/fixtures.rs
Normal file
@ -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<skia::Image> {
|
||||
(*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);
|
||||
}
|
||||
}
|
||||
86
render-wasm/src/render/svg/frames.rs
Normal file
86
render-wasm/src/render/svg/frames.rs
Normal file
@ -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 `<g filter>`; 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(())
|
||||
}
|
||||
56
render-wasm/src/render/svg/groups.rs
Normal file
56
render-wasm/src/render/svg/groups.rs
Normal file
@ -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 `<mask mask-type="alpha">` that
|
||||
// holds the fully-rendered mask subtree, so image/gradient/soft masks work
|
||||
// (a geometric `<clipPath>` 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(())
|
||||
}
|
||||
123
render-wasm/src/render/svg/masks.rs
Normal file
123
render-wasm/src/render/svg/masks.rs
Normal file
@ -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 `<mask>` (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 `<clipPath>` 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<String> {
|
||||
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 `<mask>` 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
|
||||
}
|
||||
281
render-wasm/src/render/svg/mod.rs
Normal file
281
render-wasm/src/render/svg/mod.rs
Normal file
@ -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<String>) {
|
||||
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 `<g>` 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 `<text>` 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n");
|
||||
out.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
width=\"{page_w}\" height=\"{page_h}\">"
|
||||
));
|
||||
|
||||
if !font_css.is_empty() || !defs.is_empty() {
|
||||
out.push_str("<defs>");
|
||||
if !font_css.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"<style type=\"text/css\"><![CDATA[{font_css}]]></style>"
|
||||
));
|
||||
}
|
||||
out.push_str(&defs);
|
||||
out.push_str("</defs>");
|
||||
}
|
||||
|
||||
out.push_str(&body);
|
||||
out.push_str("</svg>");
|
||||
|
||||
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 `<g>` 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 `<g opacity>` 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;
|
||||
|
||||
307
render-wasm/src/render/svg/shadows.rs
Normal file
307
render-wasm/src/render/svg/shadows.rs
Normal file
@ -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 `<filter>` 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}<feMerge>{merges}</feMerge>",
|
||||
region.x, region.y, region.width, region.height,
|
||||
);
|
||||
let id = builder.intern_def(prefix, &body, |id| format!("<filter id=\"{id}\" {body}</filter>"));
|
||||
format!("filter=\"url(#{id})\"")
|
||||
}
|
||||
|
||||
/// Emits a container's (frame/group) drop shadows as a shadow-only `<g filter>`
|
||||
/// 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 `<filter>` reproducing the shape's visible drop shadows
|
||||
/// (Penpot paints them behind the shape) and returns the `<g filter=…>`
|
||||
/// 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<String> {
|
||||
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!(
|
||||
"<feMorphology in=\"{input}\" operator=\"dilate\" \
|
||||
radius=\"{}\" result=\"{dilated}\"/>",
|
||||
shadow.spread * scale
|
||||
));
|
||||
input = dilated;
|
||||
}
|
||||
let blurred = format!("shbl{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feGaussianBlur in=\"{input}\" stdDeviation=\"{sigma}\" result=\"{blurred}\"/>"
|
||||
));
|
||||
let offset = format!("shof{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feOffset in=\"{blurred}\" dx=\"{dx}\" dy=\"{dy}\" result=\"{offset}\"/>"
|
||||
));
|
||||
let flood = format!("shfl{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feFlood flood-color=\"{hex}\" flood-opacity=\"{opacity}\" result=\"{flood}\"/>"
|
||||
));
|
||||
let tinted = format!("shad{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feComposite in=\"{flood}\" in2=\"{offset}\" \
|
||||
operator=\"in\" result=\"{tinted}\"/>"
|
||||
));
|
||||
merges.push_str(&format!("<feMergeNode in=\"{tinted}\"/>"));
|
||||
}
|
||||
if include_source {
|
||||
merges.push_str("<feMergeNode in=\"SourceGraphic\"/>");
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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!(
|
||||
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{sigma}\" result=\"{blurred}\"/>"
|
||||
));
|
||||
let occluder = format!("isof{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feOffset in=\"{blurred}\" dx=\"{dx}\" dy=\"{dy}\" result=\"{occluder}\"/>"
|
||||
));
|
||||
let band = format!("isbd{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feComposite in=\"SourceAlpha\" in2=\"{occluder}\" \
|
||||
operator=\"out\" result=\"{band}\"/>"
|
||||
));
|
||||
let flood = format!("isfl{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feFlood flood-color=\"{hex}\" flood-opacity=\"{opacity}\" result=\"{flood}\"/>"
|
||||
));
|
||||
let tinted = format!("istn{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feComposite in=\"{flood}\" in2=\"{band}\" \
|
||||
operator=\"in\" result=\"{tinted}\"/>"
|
||||
));
|
||||
let mut clip_input = tinted.clone();
|
||||
if shadow.spread > 0.0 {
|
||||
let dilated = format!("issp{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feMorphology in=\"{clip_input}\" operator=\"dilate\" \
|
||||
radius=\"{}\" result=\"{dilated}\"/>",
|
||||
shadow.spread * scale
|
||||
));
|
||||
clip_input = dilated;
|
||||
}
|
||||
let shade = format!("issh{i}");
|
||||
prims.push_str(&format!(
|
||||
"<feComposite in=\"{clip_input}\" in2=\"SourceAlpha\" \
|
||||
operator=\"in\" result=\"{shade}\"/>"
|
||||
));
|
||||
merges.push_str(&format!("<feMergeNode in=\"{shade}\"/>"));
|
||||
}
|
||||
|
||||
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 `<g filter>` 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(())
|
||||
}
|
||||
243
render-wasm/src/render/svg/strokes.rs
Normal file
243
render-wasm/src/render/svg/strokes.rs
Normal file
@ -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 `<g>` whose content (the shape stroked at double width) is clipped to
|
||||
/// the shape's *exterior* via a luminance `<mask>` (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 `<mask>` 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
|
||||
// `<clipPath>` 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
|
||||
/// `<g clip-path>` (inner) or a `<g mask>` with the inverse-of-shape luminance
|
||||
/// mask (outer, since SVG `<clipPath>` 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(())
|
||||
}
|
||||
1194
render-wasm/src/render/svg/tests/mod.rs
Normal file
1194
render-wasm/src/render/svg/tests/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,12 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="402.4282" height="252.4282"><defs><filter id="shadow0" x="-13.6803%" y="-23.7393%" width="127.3606%" height="147.4786%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="2.809401" result="shbl0"/><feOffset in="shbl0" dx="40" dy="40" result="shof0"/><feFlood flood-color="#000000" flood-opacity="0.2" result="shfl0"/><feComposite in="shfl0" in2="shof0" operator="in" result="shad0"/><feMerge><feMergeNode in="shad0"/></feMerge></filter><clipPath id="clip1" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="354" height="204"/>
|
||||
</clipPath></defs><g filter="url(#shadow0)">
|
||||
<rect fill="none" stroke="black" stroke-width="10" stroke-miterlimit="4" x="5" y="5" width="344" height="194"/>
|
||||
</g><g clip-path="url(#clip1)">
|
||||
<rect fill="none" stroke="black" stroke-width="10" stroke-miterlimit="4" x="5" y="5" width="344" height="194"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: "render(&pool, group_id)"
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="100"><g opacity="0.7">
|
||||
<rect fill="blue" width="90" height="100"/>
|
||||
<rect fill="#00C800" x="110" width="90" height="100"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: "render(&pool, group_id)"
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><defs><mask id="mask1" maskUnits="userSpaceOnUse" mask-type="alpha">
|
||||
<rect width="50" height="100"/>
|
||||
</mask></defs><g mask="url(#mask1)">
|
||||
<rect fill="red" width="100" height="100"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><defs><mask id="mask1" maskUnits="userSpaceOnUse" mask-type="alpha">
|
||||
<rect fill="#B1B2B5" y="40" width="100" height="20"/>
|
||||
<rect fill="#B1B2B5" x="40" width="20" height="100"/>
|
||||
</mask></defs><g mask="url(#mask1)">
|
||||
<ellipse fill="#1A4DE5" cx="50" cy="50" rx="50" ry="50"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: "render(&pool, id)"
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100">
|
||||
<path fill="#CCC" transform="translate(10 10)" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
<path fill="none" stroke="#080" stroke-width="10" stroke-miterlimit="4" transform="translate(10 10)" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
</svg>
|
||||
@ -0,0 +1,14 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: "render(&pool, id)"
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80">
|
||||
<path fill="#CCC" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
<clipPath id="f0_cl_3">
|
||||
<path d="M50 0L100 80L0 80L50 0Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#f0_cl_3)">
|
||||
<path fill="none" stroke="red" stroke-width="20" stroke-miterlimit="4" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="140" height="120"><defs><mask id="smask0" maskUnits="userSpaceOnUse">
|
||||
<rect fill="white" transform="translate(20 20)" x="-100000" y="-100000" width="200000" height="200000"/>
|
||||
<path transform="translate(20 20)" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
</mask></defs>
|
||||
<path fill="#CCC" transform="translate(20 20)" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
<g mask="url(#smask0)">
|
||||
<path fill="none" stroke="blue" stroke-width="20" stroke-miterlimit="4" transform="translate(20 20)" d="M50 0L100 80L0 80L50 0Z"/>
|
||||
</g></svg>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="118.42821" height="96.856415"><defs><filter id="shadow0" x="-18.4282%" y="-15.5353%" width="136.8564%" height="131.0705%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="2.809401" result="shbl0"/><feOffset in="shbl0" dx="10" dy="4" result="shof0"/><feFlood flood-color="#380000" flood-opacity="1" result="shfl0"/><feComposite in="shfl0" in2="shof0" operator="in" result="shad0"/><feMerge><feMergeNode in="shad0"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#shadow0)">
|
||||
<ellipse fill="#FBB" transform="translate(0 4.4282)" cx="50" cy="40" rx="50" ry="40"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="130.71281" height="130.71281"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="5.118802"/></filter></defs><g filter="url(#blur0)">
|
||||
<rect fill="#0080FF" transform="translate(15.3564 15.3564)" width="100" height="100"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 535
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96"><defs><mask id="simask0" maskUnits="userSpaceOnUse" mask-type="alpha">
|
||||
<rect fill="none" stroke="black" stroke-width="16" stroke-miterlimit="4" transform="translate(8 8)" width="100" height="80"/>
|
||||
</mask></defs>
|
||||
<rect fill="#CCC" transform="translate(8 8)" width="100" height="80"/>
|
||||
<g mask="url(#simask0)"><g transform="matrix(1 0 0 1 8 8)"><image x="-16" y="-16" width="132" height="112" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg=="/></g></g></svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="110" height="90">
|
||||
<rect fill="#CCC" transform="translate(5 5)" width="100" height="80"/>
|
||||
<rect fill="none" stroke="#080" stroke-width="10" stroke-miterlimit="4" transform="translate(5 5)" width="100" height="80"/>
|
||||
</svg>
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 634
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80"><defs><mask id="simask0" maskUnits="userSpaceOnUse" mask-type="alpha">
|
||||
<rect fill="none" stroke="black" stroke-width="16" stroke-miterlimit="4" x="8" y="8" width="84" height="64"/>
|
||||
</mask></defs>
|
||||
<rect fill="#CCC" width="100" height="80"/>
|
||||
<g mask="url(#simask0)"><g transform="matrix(1 0 0 1 0 0)"><image x="0" y="0" width="100" height="80" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg=="/></g></g></svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80">
|
||||
<rect fill="#CCC" width="100" height="80"/>
|
||||
<rect fill="none" stroke="red" stroke-width="10" stroke-miterlimit="4" x="5" y="5" width="90" height="70"/>
|
||||
</svg>
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 700
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112"><defs><mask id="simask0" maskUnits="userSpaceOnUse" mask-type="alpha">
|
||||
<rect fill="none" stroke="black" stroke-width="16" stroke-miterlimit="4" transform="translate(16 16)" x="-8" y="-8" width="116" height="96"/>
|
||||
</mask></defs>
|
||||
<rect fill="#CCC" transform="translate(16 16)" width="100" height="80"/>
|
||||
<g mask="url(#simask0)"><g transform="matrix(1 0 0 1 16 16)"><image x="-32" y="-32" width="164" height="144" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg=="/></g></g></svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100">
|
||||
<rect fill="#CCC" transform="translate(10 10)" width="100" height="80"/>
|
||||
<rect fill="none" stroke="blue" stroke-width="10" stroke-miterlimit="4" transform="translate(10 10)" x="-5" y="-5" width="110" height="90"/>
|
||||
</svg>
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 849
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80"><defs><clipPath id="cl0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="100" height="80"/>
|
||||
</clipPath></defs><g transform="matrix(1 0 0 1 0 0)"><image x="0" y="0" width="100" height="80" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg==" clip-path="url(#cl0)"/></g></svg>
|
||||
@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164.56921" height="134.56921"><defs><filter id="inshadow0" x="-25.2372%" y="-31.4273%" width="150.4743%" height="162.8547%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7.428203" result="isbl0"/><feOffset in="isbl0" dx="8" dy="6" result="isof0"/><feComposite in="SourceAlpha" in2="isof0" operator="out" result="isbd0"/><feFlood flood-color="#000000" flood-opacity="0.4" result="isfl0"/><feComposite in="isfl0" in2="isbd0" operator="in" result="istn0"/><feComposite in="istn0" in2="SourceAlpha" operator="in" result="issh0"/><feMerge><feMergeNode in="issh0"/></feMerge></filter></defs>
|
||||
<rect fill="#B1B2B5" transform="translate(14.2846 16.2846)" width="120" height="90"/>
|
||||
<g filter="url(#inshadow0)">
|
||||
<rect transform="translate(14.2846 16.2846)" width="120" height="90"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: "render(&pool, id)"
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80">
|
||||
<rect fill="red" width="100" height="80"/>
|
||||
</svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><g opacity="0.5" style="mix-blend-mode:multiply">
|
||||
<rect fill="#0080FF" width="100" height="100"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,15 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 1106
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="471.63" height="236.0625"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
|
||||
<text fill="#E17FDA" transform="translate(1 -4.9375)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
<defs/>
|
||||
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(1 -4.9375)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</svg>
|
||||
@ -0,0 +1,20 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 1081
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="469.63" height="234.0625"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="tclip0" clipPathUnits="userSpaceOnUse">
|
||||
<text transform="translate(0 -5.9375)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</clipPath></defs>
|
||||
<text fill="#E17FDA" transform="translate(0 -5.9375)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
<g clip-path="url(#tclip0)">
|
||||
<defs/>
|
||||
<text fill="none" stroke="blue" stroke-width="4" stroke-miterlimit="4" transform="translate(0 -5.9375)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</g></svg>
|
||||
@ -0,0 +1,21 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 1136
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="481.63" height="246.0625"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="tmask0" maskUnits="userSpaceOnUse">
|
||||
<rect fill="white" transform="translate(6 0.0625)" x="-100000" y="-100000" width="200000" height="200000"/>
|
||||
<text transform="translate(6 0.0625)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</mask></defs>
|
||||
<text fill="#E17FDA" transform="translate(6 0.0625)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
<g mask="url(#tmask0)">
|
||||
<defs/>
|
||||
<text fill="none" stroke="blue" stroke-width="12" stroke-miterlimit="4" transform="translate(6 0.0625)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</g></svg>
|
||||
@ -0,0 +1,15 @@
|
||||
---
|
||||
source: src/render/svg/tests/mod.rs
|
||||
assertion_line: 1214
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="493.4146" height="257.8471"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><filter id="inshadow0" x="-2.8379%" y="-6.6218%" width="105.6758%" height="113.2436%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="3.9641016" result="isbl0"/><feOffset in="isbl0" dx="4" dy="4" result="isof0"/><feComposite in="SourceAlpha" in2="isof0" operator="out" result="isbd0"/><feFlood flood-color="#000000" flood-opacity="0.6" result="isfl0"/><feComposite in="isfl0" in2="isbd0" operator="in" result="istn0"/><feComposite in="istn0" in2="SourceAlpha" operator="in" result="issh0"/><feMerge><feMergeNode in="issh0"/></feMerge></filter></defs>
|
||||
<text fill="#EE1919" transform="translate(7.8923 1.9548)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
<g filter="url(#inshadow0)">
|
||||
<text transform="translate(7.8923 1.9548)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984, " y="181, ">
|
||||
HOLA
|
||||
</text>
|
||||
</g></svg>
|
||||
@ -0,0 +1,14 @@
|
||||
---
|
||||
source: src/render/svg/mod.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="350" height="250"><defs><filter id="shadow0" x="-16.6667%" y="-25.0000%" width="133.3333%" height="150.0000%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="0" result="shbl0"/><feOffset in="shbl0" dx="50" dy="50" result="shof0"/><feFlood flood-color="#58EA66" flood-opacity="1" result="shfl0"/><feComposite in="shfl0" in2="shof0" operator="in" result="shad0"/><feMerge><feMergeNode in="shad0"/></feMerge></filter><clipPath id="clip1" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="300" height="200"/>
|
||||
</clipPath><clipPath id="clip2" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="300" height="200"/>
|
||||
</clipPath><filter id="shadow3" x="-16.6667%" y="-25.0000%" width="133.3333%" height="150.0000%" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="0" result="shbl0"/><feOffset in="shbl0" dx="20" dy="20" result="shof0"/><feFlood flood-color="#1900FF" flood-opacity="1" result="shfl0"/><feComposite in="shfl0" in2="shof0" operator="in" result="shad0"/><feMerge><feMergeNode in="shad0"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#shadow0)"><g clip-path="url(#clip1)">
|
||||
<rect fill="#E17FDA" x="40" y="40" width="120" height="80"/>
|
||||
</g></g><g clip-path="url(#clip2)"><g filter="url(#shadow3)">
|
||||
<rect fill="#E17FDA" x="40" y="40" width="120" height="80"/>
|
||||
</g></g></svg>
|
||||
136
render-wasm/src/render/svg/text.rs
Normal file
136
render-wasm/src/render/svg/text.rs
Normal file
@ -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 `<g opacity>` 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 `<g clip-path>` (glyph-silhouette clip)
|
||||
/// wrapping the fully-opaque double-width stroke, plus a `<g opacity>` 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 `<g mask>` (glyph-exterior mask) wrapping
|
||||
/// the fully-opaque double-width stroke, plus a `<g opacity>` 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 `<clipPath>` 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(())
|
||||
}
|
||||
@ -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<bool>,
|
||||
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
|
||||
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<f32> = 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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
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<ParagraphBuilder>],
|
||||
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<ParagraphBuilder>],
|
||||
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<ParagraphBuilder>],
|
||||
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<ParagraphBuilder>],
|
||||
layer_opacity: Option<f32>,
|
||||
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<f32>,
|
||||
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<f32>,
|
||||
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<f32>,
|
||||
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<f32>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
34
render-wasm/src/render/text_shaping.rs
Normal file
34
render-wasm/src/render/text_shaping.rs
Normal file
@ -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<String> {
|
||||
self.fonts.get_fallback()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<bool>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
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<ParagraphBuilderGroup> {
|
||||
let fonts = get_font_collection();
|
||||
let fallback_fonts = get_fallback_fonts();
|
||||
pub fn paragraph_builder_group_opaque(&self, ctx: &TextShapingCtx) -> Vec<ParagraphBuilderGroup> {
|
||||
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<PositionData> {
|
||||
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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<Vec<u8>> {
|
||||
crate::render::svg::render_to_svg(get_render_state(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
|
||||
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();
|
||||
|
||||
@ -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<String> {
|
||||
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 {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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::<shapes::PositionData>::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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user