mirror of
https://github.com/penpot/penpot.git
synced 2026-09-12 05:39:59 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54260a7ac |
No files matched your search
@@ -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
|
||||
|
||||
Generated
+70
@@ -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"
|
||||
|
||||
Executable
+144
@@ -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
|
||||
+19
-1
@@ -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
|
||||
|
||||
+64
-27
@@ -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,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+12
@@ -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>
|
||||
+9
@@ -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>
|
||||
+10
@@ -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>
|
||||
+11
@@ -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>
|
||||
+9
@@ -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>
|
||||
+14
@@ -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>
|
||||
+13
@@ -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>
|
||||
+9
File diff suppressed because one or more lines are too long.
+13
File diff suppressed because one or more lines are too long.
+12
File diff suppressed because one or more lines are too long.
+14
File diff suppressed because one or more lines are too long.
+13
File diff suppressed because one or more lines are too long.
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
+11
@@ -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>
|
||||
+9
@@ -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>
|
||||
+11
@@ -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>
|
||||
+9
@@ -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>
|
||||
+11
@@ -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>
|
||||
+9
@@ -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>
|
||||
+9
@@ -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>
|
||||
+10
@@ -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>
|
||||
+8
@@ -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>
|
||||
+8
@@ -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>
|
||||
+15
@@ -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>
|
||||
+20
@@ -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>
|
||||
+21
@@ -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>
|
||||
+15
@@ -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>
|
||||
+14
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+487
-392
File diff suppressed because it is too large.
Load diff
+40
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user