Add single-file export to SVG using the WebGL render

This commit is contained in:
Alejandro Alonso 2026-07-02 08:16:09 +02:00
parent a4ba5fc2e0
commit e54260a7ac
66 changed files with 5239 additions and 541 deletions

View File

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

View File

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

View File

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

View File

@ -130,8 +130,28 @@
(aget shape-id-buffer 3)))))
;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-url
[font-data font-url]
(when (and wasm/context-initialized? (some? font-url) (not (str/blank? font-url)))
(let [font-id-buffer (:family-id-buffer font-data)
encoder (js/TextEncoder.)
encoded (.encode encoder font-url)
size (.-byteLength encoded)
ptr (h/call wasm/internal-module "_alloc_bytes" size)
heap (gobj/get ^js wasm/internal-module "HEAPU8")
mem (js/Uint8Array. (.-buffer heap) ptr size)]
(.set mem encoded)
(h/call wasm/internal-module "_store_font_url"
(aget font-id-buffer 0)
(aget font-id-buffer 1)
(aget font-id-buffer 2)
(aget font-id-buffer 3)
(:weight font-data)
(:style font-data))
true)))
(defn- store-font-buffer
[font-data font-array-buffer emoji? fallback?]
[font-data font-array-buffer font-url emoji? fallback?]
(when wasm/context-initialized?
(let [font-id-buffer (:family-id-buffer font-data)
size (.-byteLength font-array-buffer)
@ -150,6 +170,7 @@
(:style font-data)
emoji?
fallback?)
(store-font-url font-data font-url)
true)))
;; Tracks fonts currently being fetched: {url -> fallback?}
@ -174,7 +195,7 @@
(rx/map (fn [{:keys [body]}]
(let [fallback? (get @fetching font-url fallback?)]
(swap! fetching dissoc font-url)
(store-font-buffer font-data body emoji? fallback?))))
(store-font-buffer font-data body font-url emoji? fallback?))))
(rx/catch (fn [cause]
(swap! fetching dissoc font-url)
(log/error :hint "Could not fetch font"
@ -223,7 +244,9 @@
font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (font-stored? font-data emoji?)]
(if font-stored?
(st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
(do
(store-font-url font-data uri)
(st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})))
(fetch-font font-data uri emoji? fallback?)))))
(defn serialize-font-style

70
render-wasm/Cargo.lock generated
View File

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

View File

@ -37,6 +37,9 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
thiserror = "2.0.18"
uuid = { version = "1.11.0", features = ["v4", "js"] }
[dev-dependencies]
insta = "1"
[profile.release]
opt-level = 3
lto = "fat"

144
render-wasm/preview-snapshots Executable file
View File

@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# Visual review helper for the SVG exporter's `insta` snapshots.
#
# This script renders the insta snapshots into an HTML gallery
# so you can judge whether a change is *visually* valid
# before accepting it.
#
# Usage:
# ./preview-snapshots # build the gallery and print its path
# ./preview-snapshots --open # also open it in the default browser
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
# Once a change looks correct, accept it (rename `*.snap.new` -> `*.snap`, or
# `cargo insta accept`) and re-run the tests.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SNAP_DIR="$SCRIPT_DIR/src/render/svg/tests/snapshots"
OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
extract_body() {
awk 'd>=2{print} /^---$/{d++}' "$1"
}
# Embeds a snapshot's SVG via <object> pointing at a standalone .svg file.
#
# Snapshots must NOT be inlined together into one HTML document: each SVG
# restarts its def ids at `clip0`/`blur0`/..., so inlining several in the same
# document makes `url(#clip0)` collide and resolve to the wrong (or empty) clip
# path. Writing each SVG to its own file isolates ids per document.
# We use <object> (not <img>) so linked @font-face rules and other external
# resources resolve inside the SVG document.
svg_counter=0
read_svg_dims() {
local file="$1"
local tag
tag=$(grep -m1 '<svg' "$file" | sed 's/>.*//')
SVG_W=$(echo "$tag" | sed -n 's/.*[[:space:]]width="\([^"]*\)".*/\1/p')
SVG_H=$(echo "$tag" | sed -n 's/.*[[:space:]]height="\([^"]*\)".*/\1/p')
}
emit_svg_box() {
local body_file="$OUT_DIR/svg-$svg_counter.svg"
local name
svg_counter=$((svg_counter + 1))
name="$(basename "$body_file")"
extract_body "$1" > "$body_file"
read_svg_dims "$body_file"
echo "<div class=\"cb\"><object data=\"$name\" type=\"image/svg+xml\" width=\"$SVG_W\" height=\"$SVG_H\"></object></div>"
}
{
cat <<'HTML'
<!doctype html>
<meta charset="utf-8">
<title>render-wasm SVG snapshot preview</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; margin: 24px; }
h1 { font-size: 20px; }
section { border: 1px solid #8884; border-radius: 8px; padding: 12px 16px; margin: 16px 0; }
h2 { font-size: 14px; font-family: monospace; margin: 0 0 8px; word-break: break-all; }
.cols { display: flex; gap: 24px; flex-wrap: wrap; }
figure { margin: 0; }
figcaption { font-size: 12px; color: #888; margin-bottom: 4px; }
/* Checkerboard so transparency / opacity / blend are visible. */
.cb {
display: inline-block;
line-height: 0;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
border: 1px solid #8884;
}
.cb object {
display: block;
}
.changed { color: #c60; font-weight: bold; font-size: 13px; }
details { margin-top: 8px; }
pre { background: #8881; padding: 8px; overflow: auto; font-size: 12px; }
</style>
<h1>render-wasm SVG snapshot preview</h1>
HTML
shopt -s nullglob
for snap in "$SNAP_DIR"/*.snap; do
name="$(basename "$snap" .snap)"
new="$snap.new"
echo "<section><h2>$name</h2>"
if [ -f "$new" ]; then
echo '<p class="changed">Pending change: review before accepting</p>'
echo '<div class="cols">'
echo '<figure><figcaption>accepted (current .snap)</figcaption>'
emit_svg_box "$snap"
echo '</figure>'
echo '<figure><figcaption>new (.snap.new)</figcaption>'
emit_svg_box "$new"
echo '</figure>'
echo '</div>'
echo '<details><summary>text diff</summary><pre>'
diff -u "$snap" "$new" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(())
}

View File

@ -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,
)?;
}
}

View File

@ -0,0 +1,406 @@
use std::collections::HashMap;
use skia_safe::{self as skia, Paint};
use crate::error::Result;
use crate::shapes::{radius_to_sigma, BlurType, Shape, Stroke, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget};
// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a
// `save_layer`, so composite effects (container/leaf opacity, blend mode, layer
// blur, masks) rendered using `save_layer` vanish in SVG.
//
// Instead of one canvas, the SVG path composes the document itself: leaf
// content is drawn into short-lived `skia::svg::Canvas` *fragments* (real
// `<path>`/`<text>`/… vector markup), and composite effects become native SVG
// `<g>` wrappers (`opacity`, `mix-blend-mode`, `filter="feGaussianBlur"`,
// `clip-path`). This keeps the output fully vectorial and matches the GPU
// result without rasterizing.
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
pub(super) out: String,
pub(super) defs: String,
pending: Option<skia::svg::Canvas>,
next_id: usize,
frag_no: usize,
def_cache: HashMap<String, String>,
}
impl SvgLayerCanvas {
pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self {
Self {
scale,
page_rect,
tx,
ty,
out: String::new(),
defs: String::new(),
pending: None,
next_id: 0,
frag_no: 0,
def_cache: std::collections::HashMap::new(),
}
}
pub(super) fn unique(&mut self, prefix: &str) -> String {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
id
}
/// Page transform applied to every Skia SVG fragment (`scale` then `translate`).
pub(crate) fn page_transform(&self) -> skia::Matrix {
let mut matrix = skia::Matrix::new_identity();
matrix.set_scale((self.scale, self.scale), None);
matrix.post_translate((self.tx, self.ty));
matrix
}
/// Transform for leaf content in export space: page offset/scale plus the
/// shape's `centered_transform`, matching `render_leaf`'s Skia canvas setup.
pub(crate) fn leaf_transform(&self, shape: &Shape) -> skia::Matrix {
let mut matrix = self.page_transform();
// Keep linked-image placement aligned with Skia's fragment output.
// `pre_concat` here matches the effective transform observed in emitted
// SVG from Skia fragments for rotated shapes.
matrix.pre_concat(&shape.centered_transform());
matrix
}
/// Creates a fragment canvas configured with the page transform
/// (scale + translate to the export bounds).
pub(super) fn new_fragment(&self) -> skia::svg::Canvas {
let canvas = skia::svg::Canvas::new(self.page_rect, None);
{
let cv: &skia::Canvas = &*canvas;
cv.scale((self.scale, self.scale));
cv.translate((self.tx, self.ty));
}
canvas
}
/// Returns the current leaf-drawing canvas, creating a fragment if needed.
pub(crate) fn canvas(&mut self) -> &skia::Canvas {
if self.pending.is_none() {
self.pending = Some(self.new_fragment());
}
self.pending.as_deref().unwrap()
}
/// Finalizes the pending fragment and appends its markup to `out`.
pub(super) fn flush(&mut self) {
let Some(canvas) = self.pending.take() else {
return;
};
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
if inner.trim().is_empty() {
return;
}
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
self.out.push_str(&remap_ids(inner, &prefix));
}
pub(super) fn open_group(&mut self, attrs: &str) {
self.flush();
self.out.push_str("<g ");
self.out.push_str(attrs);
self.out.push('>');
}
pub(super) fn close_group(&mut self) {
self.flush();
self.out.push_str("</g>");
}
pub(super) fn push_def(&mut self, def: &str) {
self.defs.push_str(def);
}
/// Interns a reusable `<def>` keyed by its (id-independent) body. If an
/// identical def was already emitted, its id is returned; otherwise a fresh
/// id is minted, the def emitted via `render(id)` and cached. Lets several
/// shapes share one filter instead of duplicating identical `<filter>`s.
pub(super) fn intern_def(&mut self, prefix: &str, key: &str, render: impl Fn(&str) -> String) -> String {
if let Some(id) = self.def_cache.get(key) {
return id.clone();
}
let id = self.unique(prefix);
let def = render(&id);
self.push_def(&def);
self.def_cache.insert(key.to_string(), id.clone());
id
}
/// Emits a `<clipPath>` from a mask shape's geometry (in device/page space)
/// and returns nothing; the caller wraps content in
/// `<g clip-path="url(#id)">`.
///
/// A mask can be a group too.
/// Since a group has no geometry of its own, we recurse into
/// its descendants and accumulate their geometry.
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
let canvas = self.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
draw_clip_geometry(cv, shape, tree, &paint);
}
self.finish_clip_path_fragment(id, canvas);
}
/// Renders the mask subtree rooted at `mask_id` into an isolated fragment
/// and registers it as an alpha `<mask>` def, returning the def id for the
/// caller to reference via `mask="url(#id)"`.
///
/// Unlike a geometric `<clipPath>`, this captures the mask's rendered
/// *alpha* (fills included, so images/gradients/soft masks compose
/// faithfully). The subtree is rendered
/// with a fresh [`SvgLayerCanvas`] and its ids are namespaced so they can't
/// collide with the surrounding document.
pub(super) fn push_alpha_mask(
&mut self,
shared: &mut ExportState,
mask_id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<String> {
let mut sub = SvgLayerCanvas::new(self.scale, self.page_rect, self.tx, self.ty);
super::render_tree(&mut sub, shared, mask_id, tree, scale, true)?;
sub.flush();
let prefix = format!("{}_", self.unique("m"));
let mask_ref = self.unique("mask");
let sub_defs = remap_ids(&sub.defs, &prefix);
let sub_body = remap_ids(&sub.out, &prefix);
if !sub_defs.is_empty() {
self.defs.push_str(&sub_defs);
}
self.defs.push_str(&format!(
"<mask id=\"{mask_ref}\" maskUnits=\"userSpaceOnUse\" \
mask-type=\"alpha\">{sub_body}</mask>"
));
Ok(mask_ref)
}
/// Builds an alpha `<mask>` from a stroke's opaque silhouette and returns its
/// id, for confining an image-filled stroke to the stroke region. The
/// silhouette is drawn opaque (alpha 1 in the stroke area), so `mask-type:
/// alpha` shows the image exactly where the stroke paints.
pub(super) fn push_stroke_alpha_mask(
&mut self,
shared: &mut ExportState,
element: &Shape,
stroke: &Stroke,
scale: f32,
inner_clip: bool,
) -> Result<String> {
let canvas = self.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
cv.save();
cv.concat(&element.centered_transform());
let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg);
renderer.draw_stroke_silhouette(element, stroke, inner_clip)?;
cv.restore();
}
let id = self.unique("simask");
self.finish_alpha_mask_fragment(&id, canvas);
Ok(id)
}
/// Finalizes a fragment canvas as an alpha `<mask>` def: its emitted markup's
/// *alpha* becomes the mask (opaque shows, transparent hides), independent of
/// color, unlike a luminance mask, so an opaque black silhouette works.
pub(super) fn finish_alpha_mask_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = remap_ids(inner, &prefix);
self.defs.push_str(&format!(
"<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\" \
mask-type=\"alpha\">{geometry}</mask>"
));
}
/// Finalizes a fragment canvas as a `<clipPath>` def: its emitted markup
/// (shape geometry or text glyph silhouette) becomes the clip geometry.
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = remap_ids(inner, &prefix);
self.defs.push_str(&format!(
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
}
/// Finalizes a fragment canvas as a luminance `<mask>` def: its emitted
/// markup becomes the mask (white shows, black hides).
pub(super) fn finish_mask_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = remap_ids(inner, &prefix);
self.defs.push_str(&format!(
"<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\">{geometry}</mask>"
));
}
}
/// Draws a mask's clip geometry into `cv` (already set up with the page
/// transform). Leaf shapes contribute their own geometry under their
/// `centered_transform`; groups contribute nothing themselves but recurse into
/// their children (a group carries no geometry, and its children hold absolute
/// coordinates with their own transforms, the group transform is not
/// propagated.
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
if let Type::Group(_) = &shape.shape_type {
for child_id in shape.children_ids_iter_forward(true) {
if let Some(child) = tree.get(child_id) {
draw_clip_geometry(cv, child, tree, paint);
}
}
return;
}
cv.save();
cv.concat(&shape.centered_transform());
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode, layer blur), registering any needed `<defs>`. Returns `None`
/// when the shape needs no wrapper.
pub(super) fn effect_attrs(
builder: &mut SvgLayerCanvas,
element: &Shape,
scale: f32,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
if opacity < 1.0 {
parts.push(format!("opacity=\"{opacity}\""));
}
if let Some(css) = blend_css(element.blend_mode().0) {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
if let Some(value) = layer_blur_value(element) {
let sigma = radius_to_sigma(value * scale);
let id = builder.unique("blur");
builder.push_def(&format!(
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\
<feGaussianBlur stdDeviation=\"{sigma}\"/></filter>"
));
parts.push(format!("filter=\"url(#{id})\""));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
/// Layer-blur radius of a shape, if it has a visible layer blur.
fn layer_blur_value(element: &Shape) -> Option<f32> {
element
.blur
.and_then(|b| (!b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0).then_some(b.value))
}
/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None`
/// for `SrcOver` (normal) and modes without a CSS equivalent.
fn blend_css(mode: skia::BlendMode) -> Option<&'static str> {
use skia::BlendMode::*;
Some(match mode {
Multiply => "multiply",
Screen => "screen",
Overlay => "overlay",
Darken => "darken",
Lighten => "lighten",
ColorDodge => "color-dodge",
ColorBurn => "color-burn",
HardLight => "hard-light",
SoftLight => "soft-light",
Difference => "difference",
Exclusion => "exclusion",
Hue => "hue",
Saturation => "saturation",
Color => "color",
Luminosity => "luminosity",
_ => return None,
})
}
/// Returns the inner body of a Skia SVG document (everything between the
/// opening `<svg …>` tag and the closing `</svg>`).
fn extract_inner_svg(doc: &str) -> &str {
let start = doc
.find("<svg")
.and_then(|s| doc[s..].find('>').map(|e| s + e + 1));
let end = doc.rfind("</svg>");
match (start, end) {
(Some(s), Some(e)) if s <= e => &doc[s..e],
_ => "",
}
}
/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…`
/// references) so ids stay unique once fragments are merged into one document.
fn remap_ids(body: &str, prefix: &str) -> String {
// Collect id definitions.
let needle = "id=\"";
let mut ids: Vec<&str> = Vec::new();
let mut offset = 0;
while let Some(pos) = body[offset..].find(needle) {
let start = offset + pos + needle.len();
let Some(end_rel) = body[start..].find('"') else {
break;
};
let id = &body[start..start + end_rel];
if !id.is_empty() {
ids.push(id);
}
offset = start + end_rel + 1;
}
ids.sort_unstable();
ids.dedup();
// Longest-first so a shorter id can't collide inside a longer one; the
// quote/paren delimiters below already prevent partial matches.
ids.sort_by(|a, b| b.len().cmp(&a.len()));
let mut out = body.to_string();
for id in ids {
let new_id = format!("{prefix}{id}");
out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\""));
out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})"));
out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\""));
}
out
}

View File

@ -0,0 +1,411 @@
//! GPU-free scene builders and render helpers for SVG export tests.
use skia_safe as skia;
use crate::render::vector::ExportState;
use crate::render::{FontStore, ImageProvider};
use crate::shapes::{
Fill, FontFamily, FontStyle, GrowType, ImageFill, Paragraph, Path, Rect, Segment, SolidColor,
Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan, Type,
};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
use super::render_tree_to_svg;
/// Default image URL used by SVG export tests.
pub(super) const TEST_IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAABHNCSVQICAgIfAhkiAAAABtJREFUCJlj/G/M8J8x7T8DA8PM////GzP8BwBGtwhh9BmShgAAAABJRU5ErkJggg==";
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
}
/// Minimal [`ExportState`] for tests without image draws. Image tests build
/// their own context with [`render_with_images`].
pub(super) fn export_state(fonts: &FontStore) -> ExportState<'_> {
ExportState {
fonts,
images: None,
sampling_options: skia::SamplingOptions::new(
skia::FilterMode::Linear,
skia::MipmapMode::Nearest,
),
}
}
/// Deterministic UUID from a small integer, keeping snapshots stable.
pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
/// Adds a solid-filled rectangle to the pool and returns nothing; callers
/// tweak the returned shape via `pool.get_mut` when they need effects.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
let mut fonts = FontStore::try_new().expect("font store");
register_test_font_urls(&mut fonts);
let mut ctx = export_state(&fonts);
let bytes = render_tree_to_svg(&mut ctx, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
}
/// Adds a solid-filled rectangle with a single solid stroke of the given
/// kind/width/color.
pub(super) fn add_stroked_rect(
pool: &mut ShapesPool,
id: Uuid,
rect: (f32, f32, f32, f32),
fill: skia::Color,
kind: StrokeKind,
width: f32,
stroke_color: skia::Color,
) {
add_styled_stroked_rect(
pool,
id,
rect,
fill,
kind,
StrokeStyle::Solid,
width,
stroke_color,
);
}
/// Adds a solid-filled rectangle with a single stroke of the given
/// kind/style/width/color.
pub(super) fn add_styled_stroked_rect(
pool: &mut ShapesPool,
id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fill: skia::Color,
kind: StrokeKind,
style: StrokeStyle,
width: f32,
stroke_color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(fill))]);
let mut stroke = match kind {
StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
};
stroke.fill = Fill::Solid(SolidColor(stroke_color));
shape.add_stroke(stroke);
}
/// A CPU-only [`ImageProvider`] returning one tiny raster image and its source
/// URL for a fixed id, so image-stroke tests need no GPU-backed `ImageStore`.
pub(super) struct FakeImages {
pub id: Uuid,
pub image: skia::Image,
pub url: &'static str,
}
impl ImageProvider for FakeImages {
fn get_cpu_image(&mut self, id: &Uuid) -> Option<skia::Image> {
(*id == self.id).then(|| self.image.clone())
}
fn source_url(&self, id: &Uuid) -> Option<&str> {
(*id == self.id).then_some(self.url)
}
}
/// A 2×2 non-uniform raster image (kept tiny so its base64 stays small and
/// deterministic in snapshots).
pub(super) fn tiny_image() -> skia::Image {
let info = skia::ImageInfo::new_n32_premul((2, 2), None);
let mut surface = skia::surfaces::raster(&info, None, None).expect("raster surface");
let canvas = surface.canvas();
canvas.clear(skia::Color::from_rgb(0x00, 0x99, 0xFF));
let mut paint = skia::Paint::default();
paint.set_color(skia::Color::from_rgb(0xFF, 0x33, 0x00));
canvas.draw_rect(skia::Rect::from_xywh(0.0, 0.0, 1.0, 1.0), &paint);
canvas.draw_rect(skia::Rect::from_xywh(1.0, 1.0, 1.0, 1.0), &paint);
surface.image_snapshot()
}
/// Renders `root` with an image provider available (image draws enabled).
pub(super) fn render_with_images(
pool: &ShapesPool,
root: Uuid,
images: &mut dyn ImageProvider,
) -> String {
let mut fonts = FontStore::try_new().expect("font store");
register_test_font_urls(&mut fonts);
let mut ctx = ExportState {
fonts: &fonts,
images: Some(images),
sampling_options: skia::SamplingOptions::new(
skia::FilterMode::Linear,
skia::MipmapMode::Nearest,
),
};
let bytes = render_tree_to_svg(&mut ctx, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
}
/// Applies a rotation (degrees) around the shape's center.
///
/// SVG export composes geometry via `Shape::centered_transform()`, which reads
/// `transform` rather than the standalone `rotation` field. Tests therefore
/// need to keep both values in sync.
pub(super) fn rotate_shape(pool: &mut ShapesPool, id: Uuid, degrees: f32) {
let shape = pool.get_mut(&id).unwrap();
shape.set_rotation(degrees);
let mut transform = skia::Matrix::new_identity();
transform.set_rotate(degrees, None);
shape.set_transform(
transform[0],
transform[3],
transform[1],
transform[4],
transform[2],
transform[5],
);
}
/// Sets the opacity of an image-filled stroke (0-255).
pub(super) fn set_image_stroke_opacity(pool: &mut ShapesPool, id: Uuid, opacity: u8) {
let shape = pool.get_mut(&id).unwrap();
let Some(stroke) = shape.strokes.first_mut() else {
return;
};
let Fill::Image(image_fill) = &stroke.fill else {
return;
};
stroke.fill = Fill::Image(ImageFill::new(
image_fill.id(),
opacity,
image_fill.width(),
image_fill.height(),
image_fill.keep_aspect_ratio(),
));
}
/// Adds a solid-filled rectangle whose single stroke is *image-filled* (the
/// stroke references `image_id` via an [`ImageFill`]).
pub(super) fn add_image_stroked_rect(
pool: &mut ShapesPool,
id: Uuid,
image_id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fill: skia::Color,
kind: StrokeKind,
style: StrokeStyle,
width: f32,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(fill))]);
let mut stroke = match kind {
StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
};
stroke.fill = Fill::Image(ImageFill::new(image_id, 255, 2, 2, false));
shape.add_stroke(stroke);
}
/// Adds a rectangle whose *fill* is an image (references `image_id` via an
/// [`ImageFill`]).
pub(super) fn add_image_filled_rect(
pool: &mut ShapesPool,
id: Uuid,
image_id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Image(ImageFill::new(image_id, 255, 2, 2, false))]);
}
/// Adds a closed triangular path whose *fill* is an image (references `image_id`
/// via an [`ImageFill`]).
pub(super) fn add_image_filled_path(
pool: &mut ShapesPool,
id: Uuid,
image_id: Uuid,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Path(Path::new(vec![
Segment::MoveTo((50.0, 0.0)),
Segment::LineTo((100.0, 80.0)),
Segment::LineTo((0.0, 80.0)),
Segment::Close,
])));
shape.set_selrect(0.0, 0.0, 100.0, 80.0);
shape.set_fills(vec![Fill::Image(ImageFill::new(image_id, 255, 2, 2, false))]);
}
/// Adds a solid-filled closed triangular path whose single stroke is
/// *image-filled* (references `image_id` via an [`ImageFill`]).
pub(super) fn add_image_stroked_path(
pool: &mut ShapesPool,
id: Uuid,
image_id: Uuid,
fill: skia::Color,
kind: StrokeKind,
width: f32,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Path(Path::new(vec![
Segment::MoveTo((50.0, 0.0)),
Segment::LineTo((100.0, 80.0)),
Segment::LineTo((0.0, 80.0)),
Segment::Close,
])));
shape.set_selrect(0.0, 0.0, 100.0, 80.0);
shape.set_fills(vec![Fill::Solid(SolidColor(fill))]);
let mut stroke = match kind {
StrokeKind::Inner => {
Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Center => {
Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Outer => {
Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
};
stroke.fill = Fill::Image(ImageFill::new(image_id, 255, 2, 2, false));
shape.add_stroke(stroke);
}
/// Adds a solid-filled closed triangular path with a single solid stroke of
/// the given kind/width/color. Closed paths honor the stroke kind (open
/// paths always render centered).
pub(super) fn add_stroked_path(
pool: &mut ShapesPool,
id: Uuid,
fill: skia::Color,
kind: StrokeKind,
width: f32,
stroke_color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Path(Path::new(vec![
Segment::MoveTo((50.0, 0.0)),
Segment::LineTo((100.0, 80.0)),
Segment::LineTo((0.0, 80.0)),
Segment::Close,
])));
shape.set_selrect(0.0, 0.0, 100.0, 80.0);
shape.set_fills(vec![Fill::Solid(SolidColor(fill))]);
let mut stroke = match kind {
StrokeKind::Inner => {
Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Center => {
Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Outer => {
Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
};
stroke.fill = Fill::Solid(SolidColor(stroke_color));
shape.add_stroke(stroke);
}
/// Adds a single-line text shape ("HOLA"-style) using the embedded default
/// font (registered under the nil-UUID family, weight 400), optionally with
/// a single solid stroke of the given kind. Font shaping resolves against the
/// export `FontStore` (installed for the render), so no global state or GPU
/// is needed.
pub(super) fn add_text(
pool: &mut ShapesPool,
id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
text: &str,
font_size: f32,
fill: skia::Color,
stroke: Option<(StrokeKind, f32, skia::Color)>,
) {
let bounds = skia::Rect::from_ltrb(l, t, r, b);
let mut content = TextContent::new(bounds, GrowType::Fixed);
// `line_height` is a multiplier of the font size (Skia `set_height` with
// height override), NOT an absolute pixel value.
let line_height = 1.2;
let span = TextSpan::new(
text.to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
font_size,
line_height,
0.0,
None,
None,
TextDirection::LTR,
400,
Uuid::nil(),
vec![Fill::Solid(SolidColor(fill))],
);
content.add_paragraph(Paragraph::new(
TextAlign::Left,
TextDirection::LTR,
None,
None,
line_height,
0.0,
vec![span],
));
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
// Set the selrect *before* the text type: `set_selrect` on a text shape
// eagerly relayouts (needing the font collection), which isn't available
// until the export installs it. The render recomputes text layout from
// the selrect anyway.
shape.set_selrect(l, t, r, b);
shape.set_shape_type(Type::Text(content));
if let Some((kind, width, color)) = stroke {
let mut stroke = match kind {
StrokeKind::Inner => {
Stroke::new_inner_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Center => {
Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
StrokeKind::Outer => {
Stroke::new_outer_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
};
stroke.fill = Fill::Solid(SolidColor(color));
shape.add_stroke(stroke);
}
}

View File

@ -0,0 +1,86 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::render_tree;
use super::shadows::{render_container_drop_shadow, render_inner_shadows};
use super::strokes::render_image_strokes;
use crate::render::vector::{children_paint_order, ExportState, VectorRenderer, VectorTarget};
pub(super) fn render_frame(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
render_shadows: bool,
) -> Result<()> {
let matrix = element.centered_transform();
let effects = effect_attrs(builder, element, scale);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
// Frame drop shadow: like the GPU/PDF path, the silhouette is the frame's
// rendered content (background + strokes + children), but the descendants'
// *own* drop shadows must not bleed into it (that would trace a second,
// doubled silhouette). It is emitted as a separate shadow-only pass drawn
// behind the content, sitting outside the content clip so the offset shadow
// is not clipped to the frame bounds.
if render_shadows {
render_container_drop_shadow(builder, shared, element, tree, scale)?;
}
let clipped = element.clip_content;
if clipped {
let clip_id = builder.unique("clip");
builder.push_clip_path(&clip_id, element, tree);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background + inner shadows (frame space).
if !element.fills.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
}
// Frame-background inner shadows (native `<g filter>`; the shared renderer's
// `save_layer` version is dropped by `SkSVGDevice`). Over the background,
// under the children, matching the GPU order.
render_inner_shadows(builder, shared, element, scale)?;
// Children (absolute coords).
for child_id in &children_paint_order(tree, element) {
render_tree(builder, shared, child_id, tree, scale, render_shadows)?;
}
// Strokes over children (frame space).
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
// Image-filled frame strokes are deferred by the shared renderer; re-emit
// the texture masked to the stroke region.
render_image_strokes(builder, shared, element, scale)?;
}
if clipped {
builder.close_group();
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}

View File

@ -0,0 +1,56 @@
use crate::error::Result;
use crate::shapes::Shape;
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::render_tree;
use super::shadows::render_container_drop_shadow;
use crate::render::vector::{children_paint_order, ExportState};
pub(super) fn render_group(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
masked: bool,
tree: ShapesPoolRef,
scale: f32,
render_shadows: bool,
) -> Result<()> {
let effects = effect_attrs(builder, element, scale);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
// Group drop shadow: the silhouette is the group's subtree (rendered
// without its descendants' own shadows), drawn behind the content.
if render_shadows {
render_container_drop_shadow(builder, shared, element, tree, scale)?;
}
// A Penpot mask is an *alpha* mask: content is clipped to the mask shape's
// rendered alpha (geometry AND fill alpha), not merely its outline. Mirror
// the GPU/PDF `DstIn` compose with an SVG `<mask mask-type="alpha">` that
// holds the fully-rendered mask subtree, so image/gradient/soft masks work
// (a geometric `<clipPath>` can only capture the outline).
let mask_id = masked
.then(|| element.mask_id())
.flatten()
.filter(|mid| tree.get(mid).is_some())
.copied();
if let Some(mid) = mask_id {
let mask_ref = builder.push_alpha_mask(shared, &mid, tree, scale)?;
builder.open_group(&format!("mask=\"url(#{mask_ref})\""));
}
for child_id in &children_paint_order(tree, element) {
render_tree(builder, shared, child_id, tree, scale, render_shadows)?;
}
if mask_id.is_some() {
builder.close_group();
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}

View File

@ -0,0 +1,123 @@
use skia_safe::{self as skia, Paint};
use crate::error::Result;
use crate::shapes::{Shape, Stroke};
use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget};
use super::document::SvgLayerCanvas;
const MASK_CANVAS: f32 = 100_000.0;
/// Registers a luminance `<mask>` (white canvas minus a black silhouette) and
/// returns its id, keeping only content *outside* the silhouette.
fn push_inverse_mask(
builder: &mut SvgLayerCanvas,
prefix: &str,
matrix: &skia::Matrix,
draw_silhouette: impl FnOnce(&skia::Canvas),
) -> String {
let mask_id = builder.unique(prefix);
let canvas = builder.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
let mut white = Paint::default();
white.set_color(skia::Color::WHITE);
cv.draw_rect(
skia::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS),
&white,
);
cv.save();
cv.concat(matrix);
draw_silhouette(cv);
cv.restore();
}
builder.finish_mask_fragment(&mask_id, canvas);
mask_id
}
/// Registers a `<clipPath>` from a leaf shape's geometry and returns its id.
pub(super) fn push_leaf_clip_path(
builder: &mut SvgLayerCanvas,
element: &Shape,
matrix: &skia::Matrix,
) -> String {
let clip_id = builder.unique("dclip");
let canvas = builder.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
cv.concat(matrix);
let mut black = Paint::default();
black.set_anti_alias(true);
black.set_color(skia::Color::BLACK);
draw_shape_geometry(cv, element, &black);
}
builder.finish_clip_path_fragment(&clip_id, canvas);
clip_id
}
/// Inverse-of-shape luminance mask: keeps only content outside the shape.
pub(super) fn push_inverse_shape_mask(
builder: &mut SvgLayerCanvas,
element: &Shape,
matrix: &skia::Matrix,
prefix: &str,
) -> String {
push_inverse_mask(builder, prefix, matrix, |cv| {
let mut black = Paint::default();
black.set_anti_alias(true);
black.set_color(skia::Color::BLACK);
draw_shape_geometry(cv, element, &black);
})
}
/// Inverse-of-glyph luminance mask: keeps only content outside the text glyphs.
pub(super) fn push_inverse_glyph_mask(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
matrix: &skia::Matrix,
scale: f32,
) -> Result<String> {
let mask_id = builder.unique("tmask");
let canvas = builder.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
let mut white = Paint::default();
white.set_color(skia::Color::WHITE);
cv.draw_rect(
skia::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS),
&white,
);
cv.save();
cv.concat(matrix);
let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg);
renderer.draw_text_glyph_silhouette(element)?;
cv.restore();
}
builder.finish_mask_fragment(&mask_id, canvas);
Ok(mask_id)
}
/// Alpha `<mask>` of a stroke's boundary-centered dot ring.
pub(super) fn push_dotted_ring_alpha_mask(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &Stroke,
matrix: &skia::Matrix,
) -> String {
let svg_attrs = element.svg_attrs.as_ref();
let mask_id = builder.unique("dimask");
let canvas = builder.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
cv.concat(matrix);
// Keep the dotted paint's dash effect and width, but paint it opaque so
// the alpha mask captures the exact dot footprint (fill is irrelevant).
let mut paint = stroke.to_paint(&element.selrect, svg_attrs, true);
paint.set_shader(None);
paint.set_color(skia::Color::BLACK);
draw_shape_geometry(cv, element, &paint);
}
builder.finish_alpha_mask_fragment(&mask_id, canvas);
mask_id
}

View File

@ -0,0 +1,281 @@
use std::collections::HashSet;
use skia_safe::{self as skia};
use crate::error::Result;
use crate::render::vector::ExportState;
use crate::shapes::Type;
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::RenderState;
/// Collects the registered font aliases used by every text span in the subtree
/// rooted at `id`, so the exporter can emit `@font-face` rules for them.
fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet<String>) {
let Some(shape) = tree.get(id) else {
return;
};
if let Type::Text(_) = &shape.shape_type {
for paragraph in shape.get_text_content().paragraphs() {
for span in paragraph.children() {
out.insert(format!("{}", span.font_family));
}
}
}
for child_id in shape.children_ids_iter_forward(true) {
collect_font_aliases(tree, child_id, out);
}
}
/// Renders a shape tree to an SVG document and returns the raw SVG bytes.
///
/// Dedicated vector-SVG render path. Leaf content (paths, text, fills, images)
/// is emitted as real SVG markup via short-lived Skia SVG canvases, while
/// composite effects (container/leaf opacity, blend mode, layer blur, masks)
/// are composed as native SVG `<g>` wrappers (`opacity`, `mix-blend-mode`,
/// `filter="feGaussianBlur"`, `clip-path`). This keeps everything vectorial and
/// faithful to the GPU/PDF output, sidestepping `SkSVGDevice`'s inability to
/// keep `save_layer` content.
///
/// Text is emitted as real `<text>` elements (no `CONVERT_TEXT_TO_PATHS`), so
/// the output stays selectable/editable. Skia's SVG backend does not embed
/// fonts, so we inject `@font-face` rules that reference the source URLs
/// registered at load time.
pub fn render_to_svg(
shared: &mut RenderState,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
let mut ctx = ExportState {
fonts: &shared.fonts,
images: Some(&mut shared.images as &mut dyn super::ImageProvider),
sampling_options: shared.sampling_options,
};
render_tree_to_svg(&mut ctx, id, tree, scale)
}
/// Core SVG export, decoupled from `RenderState` via [`ExportState`] so it can
/// run on a plain CPU Skia canvas (and in headless native tests, which cannot
/// build a GPU-backed `RenderState`).
pub(crate) fn render_tree_to_svg(
shared: &mut ExportState,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
let text_ctx = shared.text_ctx(crate::utils::Browser::Chrome);
let shape = tree
.get(id)
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?;
let bounds = shape.extrect(tree, scale, &text_ctx);
let page_w = bounds.width() * scale;
let page_h = bounds.height() * scale;
let rect = skia::Rect::from_xywh(0., 0., page_w, page_h);
let (defs, body) =
render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?;
// Emit @font-face rules for fonts used in the subtree so viewers can load them.
let mut aliases = HashSet::new();
collect_font_aliases(tree, id, &mut aliases);
let font_css = shared.fonts.font_face_css_for_aliases(&aliases);
let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256);
out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n");
out.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
width=\"{page_w}\" height=\"{page_h}\">"
));
if !font_css.is_empty() || !defs.is_empty() {
out.push_str("<defs>");
if !font_css.is_empty() {
out.push_str(&format!(
"<style type=\"text/css\"><![CDATA[{font_css}]]></style>"
));
}
out.push_str(&defs);
out.push_str("</defs>");
}
out.push_str(&body);
out.push_str("</svg>");
Ok(out.into_bytes())
}
mod document;
mod frames;
mod groups;
mod images;
mod masks;
mod shadows;
mod strokes;
mod text;
pub(crate) use document::SvgLayerCanvas;
pub(crate) use images::{
emit_linked_image_fill, emit_linked_image_stroke,
};
use crate::render::vector::{render_leaf_content, VectorRenderer, VectorTarget};
use crate::shapes::Shape;
use document::effect_attrs;
use frames::render_frame;
use groups::render_group;
use shadows::{drop_shadow_attr, render_inner_shadows};
use strokes::{render_image_strokes, render_path_outer_strokes, render_rect_circle_dotted_strokes};
use text::{render_text_alpha_strokes, render_text_inner_strokes, render_text_outer_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
shared: &mut ExportState,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
) -> Result<(String, String)> {
let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty);
render_tree(&mut builder, shared, id, tree, scale, true)?;
builder.flush();
Ok((builder.defs, builder.out))
}
/// `render_shadows` is `false` while building a container's drop-shadow
/// silhouette: descendants must contribute their *shape* alpha but not their own
/// drop shadows (matching the GPU, which clears nested shadows when rendering a
/// container shadow). It is `true` for the normal document render.
fn render_tree(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
render_shadows: bool,
) -> Result<()> {
let Some(element) = tree.get(id) else {
return Ok(());
};
if element.hidden {
return Ok(());
}
match &element.shape_type {
Type::Group(group) => {
render_group(builder, shared, element, group.masked, tree, scale, render_shadows)
}
Type::Frame(_) => render_frame(builder, shared, element, tree, scale, render_shadows),
Type::Rect(_)
| Type::Circle
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale, render_shadows),
}
}
fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
scale: f32,
render_shadows: bool,
) -> Result<()> {
let effects = effect_attrs(builder, element, scale);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
// Drop shadows sit behind (and below the opacity/blend layer of) the shape.
// `SkSVGDevice` drops the GPU/PDF `save_layer` shadow, so emit a native SVG
// filter and let it produce the shadow + the shape on top. Suppressed while
// rendering a parent container's shadow silhouette (`render_shadows` false).
let shadow = if render_shadows {
drop_shadow_attr(builder, element, scale, true)
} else {
None
};
if let Some(attrs) = &shadow {
builder.open_group(attrs);
}
{
let matrix = element.centered_transform();
let mut renderer =
VectorRenderer::new_svg_layer(builder, shared, scale, VectorTarget::Svg);
{
let canvas = renderer.skia_canvas();
canvas.save();
canvas.concat(&matrix);
}
render_leaf_content(&mut renderer, element)?;
{
let canvas = renderer.skia_canvas();
canvas.restore();
}
}
// Inner shadows compose inside a `save_layer` the SVG backend drops; re-emit
// them natively over the fill (matches the GPU fill/inner-shadow/stroke order).
render_inner_shadows(builder, shared, element, scale)?;
// Outer strokes on closed paths/bools are deferred by the shared renderer on
// SVG (their `save_layer` + `Clear` composition is dropped by `SkSVGDevice`);
// re-emit them as a nested `<g>` clipped to the shape's exterior.
render_path_outer_strokes(builder, element, scale)?;
// Dotted inner/outer strokes on rect/circle are likewise deferred (their
// boundary-ring clip lives in a dropped `save_layer`); re-emit them here
// clipped/masked to the shape interior/exterior.
render_rect_circle_dotted_strokes(builder, element)?;
// Image-filled strokes are deferred too (their `SrcIn` composition lives in a
// dropped `save_layer`); re-emit the texture masked to the stroke region.
render_image_strokes(builder, shared, element, scale)?;
// Semi-transparent text strokes are skipped by the shared renderer on SVG
// (their opacity layer is a dropped `save_layer`); re-emit them here inside
// a native `<g opacity>` wrapping the fully-opaque stroke.
if let Type::Text(_) = &element.shape_type {
render_text_alpha_strokes(builder, shared, element, scale)?;
render_text_inner_strokes(builder, shared, element, scale)?;
render_text_outer_strokes(builder, shared, element, scale)?;
}
if shadow.is_some() {
builder.close_group();
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}
// ===========================================================================
// Tests
// ===========================================================================
//
// These are fast, headless native tests (`cargo test --bin render_wasm`) for
// the SVG exporter. They bypass the GPU/browser stack entirely: shapes are
// built directly into a `ShapesPool` and rendered through
// [`render_tree_to_svg`] with a GPU-free [`ExportState`] (standalone
// [`FontStore`]; image tests use [`FakeImages`] via [`render_with_images`]).
// Output is checked with `insta` snapshots.
//
// To (re)generate snapshots after a deliberate change:
// cargo insta test --accept --bin render_wasm
// (or run the tests and `cargo insta accept`).
#[cfg(test)]
mod fixtures;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,307 @@
use skia_safe::{self as skia, Paint};
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::shapes::{radius_to_sigma, Shadow, Shape, Stroke, Type};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use super::render_tree;
use crate::render::vector::{children_paint_order, draw_shape_geometry, ExportState, VectorRenderer, VectorTarget};
/// Adaptive `<filter>` region as `objectBoundingBox` percentages.
struct FilterRegion {
x: f32,
y: f32,
width: f32,
height: f32,
}
fn filter_region(bbox: skia::Rect, scale: f32, margin_x: f32, margin_y: f32) -> FilterRegion {
let w = (bbox.width() * scale).abs().max(1.0);
let h = (bbox.height() * scale).abs().max(1.0);
FilterRegion {
x: -margin_x / w * 100.0,
y: -margin_y / h * 100.0,
width: (1.0 + 2.0 * margin_x / w) * 100.0,
height: (1.0 + 2.0 * margin_y / h) * 100.0,
}
}
fn track_shadow_reach(
margin_x: f32,
margin_y: f32,
sigma: f32,
spread: f32,
scale: f32,
dx: f32,
dy: f32,
) -> (f32, f32) {
let reach = 3.0 * sigma + spread * scale;
(
margin_x.max(reach + dx.abs()),
margin_y.max(reach + dy.abs()),
)
}
fn shadow_tint(shadow: &Shadow) -> (String, f32) {
let color = shadow.color;
(
format!("#{:02X}{:02X}{:02X}", color.r(), color.g(), color.b()),
color.a() as f32 / 255.0,
)
}
fn intern_filter_attr(
builder: &mut SvgLayerCanvas,
prefix: &str,
region: &FilterRegion,
prims: &str,
merges: &str,
) -> String {
let body = format!(
"x=\"{:.4}%\" y=\"{:.4}%\" width=\"{:.4}%\" height=\"{:.4}%\" \
primitiveUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\">\
{prims}<feMerge>{merges}</feMerge>",
region.x, region.y, region.width, region.height,
);
let id = builder.intern_def(prefix, &body, |id| format!("<filter id=\"{id}\" {body}</filter>"));
format!("filter=\"url(#{id})\"")
}
/// Emits a container's (frame/group) drop shadows as a shadow-only `<g filter>`
/// group drawn *behind* the content. The group holds the container's silhouette:
/// its own fills/strokes plus its descendants rendered with `render_shadows =
/// false`, so the filter's `SourceAlpha` is the shape silhouette *without* the
/// descendants' own drop shadows (which would otherwise cast a second, offset
/// green copy: the "doubled shadow"). The filter merges only the tinted/offset
/// shadow (no `SourceGraphic`), since the real content is drawn separately on
/// top.
///
/// For a frame, the descendants' silhouette is clipped to the frame geometry,
/// mirroring the GPU (`get_nested_shadow_clip_bounds` clips each child's shadow
/// to the frame selrect). The frame's *own* fills/strokes are left unclipped
/// (the GPU renders them with `clip_content = false`). The clip is applied
/// pre-offset; the filter's `feOffset` then shifts the clipped silhouette,
/// matching the GPU's selrect-shifted-by-offset clip.
pub(super) fn render_container_drop_shadow(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let Some(attrs) = drop_shadow_attr(builder, element, scale, false) else {
return Ok(());
};
let matrix = element.centered_transform();
builder.open_group(&attrs);
// Frame background silhouette (frame space, unclipped).
if !element.fills.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
}
// Descendants (absolute coords), with their own shadows suppressed. Clipped
// to the frame geometry to match the GPU nested-shadow clip.
let children = children_paint_order(tree, element);
let clip_children = !children.is_empty() && matches!(element.shape_type, Type::Frame(_));
if clip_children {
let clip_id = builder.unique("clip");
builder.push_clip_path(&clip_id, element, tree);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale, false)?;
}
if clip_children {
builder.close_group();
}
// Frame strokes silhouette (frame space, unclipped).
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
}
builder.close_group();
Ok(())
}
/// Registers an SVG `<filter>` reproducing the shape's visible drop shadows
/// (Penpot paints them behind the shape) and returns the `<g filter=…>`
/// attribute referencing it, or `None` when there are none.
pub(super) fn drop_shadow_attr(
builder: &mut SvgLayerCanvas,
element: &Shape,
scale: f32,
include_source: bool,
) -> Option<String> {
let shadows: Vec<&Shadow> = element.drop_shadows_visible().collect();
if shadows.is_empty() {
return None;
}
let mut margin_x = 0.0_f32;
let mut margin_y = 0.0_f32;
let mut prims = String::new();
let mut merges = String::new();
for (i, shadow) in shadows.iter().enumerate() {
let sigma = radius_to_sigma(shadow.blur * scale);
let dx = shadow.offset.0 * scale;
let dy = shadow.offset.1 * scale;
(margin_x, margin_y) = track_shadow_reach(margin_x, margin_y, sigma, shadow.spread, scale, dx, dy);
let (hex, opacity) = shadow_tint(shadow);
let mut input = "SourceAlpha".to_string();
if shadow.spread > 0.0 {
let dilated = format!("shsp{i}");
prims.push_str(&format!(
"<feMorphology in=\"{input}\" operator=\"dilate\" \
radius=\"{}\" result=\"{dilated}\"/>",
shadow.spread * scale
));
input = dilated;
}
let blurred = format!("shbl{i}");
prims.push_str(&format!(
"<feGaussianBlur in=\"{input}\" stdDeviation=\"{sigma}\" result=\"{blurred}\"/>"
));
let offset = format!("shof{i}");
prims.push_str(&format!(
"<feOffset in=\"{blurred}\" dx=\"{dx}\" dy=\"{dy}\" result=\"{offset}\"/>"
));
let flood = format!("shfl{i}");
prims.push_str(&format!(
"<feFlood flood-color=\"{hex}\" flood-opacity=\"{opacity}\" result=\"{flood}\"/>"
));
let tinted = format!("shad{i}");
prims.push_str(&format!(
"<feComposite in=\"{flood}\" in2=\"{offset}\" \
operator=\"in\" result=\"{tinted}\"/>"
));
merges.push_str(&format!("<feMergeNode in=\"{tinted}\"/>"));
}
if include_source {
merges.push_str("<feMergeNode in=\"SourceGraphic\"/>");
}
let region = filter_region(element.selrect(), scale, margin_x, margin_y);
Some(intern_filter_attr(builder, "shadow", &region, &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", &region, &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(())
}

View File

@ -0,0 +1,243 @@
use skia_safe::{self as skia};
use crate::error::Result;
use crate::shapes::{Fill, Shape, StrokeKind, Type};
use super::document::SvgLayerCanvas;
use super::masks::{push_dotted_ring_alpha_mask, push_inverse_shape_mask, push_leaf_clip_path};
use crate::render::vector::{draw_shape_geometry, ExportState, VectorRenderer, VectorTarget};
/// Alpha mask of a path/bool stroked at double width (no inner clear). Combined
/// with an exterior luminance mask this yields only the outer stroke band, the
/// same composition `render_path_outer_strokes` uses for solid strokes, without
/// relying on `stroke_to_path` boolean ops (which emit even-odd self-intersections
/// in SVG for rotated paths).
fn push_path_double_stroke_alpha_mask(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &crate::shapes::Stroke,
) -> String {
let svg_attrs = element.svg_attrs.as_ref();
let canvas = builder.new_fragment();
{
let cv: &skia::Canvas = &*canvas;
cv.save();
cv.concat(&element.centered_transform());
let mut paint = stroke.to_stroked_paint(false, &element.selrect, svg_attrs, true);
paint.set_shader(None);
paint.set_color(skia::Color::BLACK);
draw_shape_geometry(cv, element, &paint);
cv.restore();
}
let id = builder.unique("simask");
builder.finish_alpha_mask_fragment(&id, canvas);
id
}
/// Re-emits the *outer* solid/gradient strokes of a closed path/bool as a
/// nested `<g>` whose content (the shape stroked at double width) is clipped to
/// the shape's *exterior* via a luminance `<mask>` (white canvas minus the
/// shape silhouette). This mirrors the GPU/PDF `save_layer` + `Clear` (DstOut of
/// the shape), which `SkSVGDevice` drops, so the shared renderer skips these on
/// SVG and lets the compositor nest them here.
///
/// Image-fill outer strokes on path/bool are handled by `render_image_strokes`
/// (same exterior-mask trick; see there for why).
pub(super) fn render_path_outer_strokes(
builder: &mut SvgLayerCanvas,
element: &Shape,
_scale: f32,
) -> Result<()> {
if !matches!(element.shape_type, Type::Path(_) | Type::Bool(_)) || element.is_open() {
return Ok(());
}
let matrix = element.centered_transform();
for stroke in element.visible_strokes() {
if stroke.render_kind(false) != StrokeKind::Outer {
continue;
}
// Image-filled outer strokes are composed by `render_image_strokes` via
// an alpha mask of the stroke region; don't re-emit them here as a
// solid stroke (it would show up as a second outline).
if matches!(stroke.fill, Fill::Image(_)) {
continue;
}
let mask_id = push_inverse_shape_mask(builder, element, &matrix, "smask");
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let svg_attrs = element.svg_attrs.as_ref();
let paint = stroke.to_stroked_paint(false, &element.selrect, svg_attrs, true);
draw_shape_geometry(canvas, element, &paint);
canvas.restore();
}
builder.close_group();
}
Ok(())
}
/// Re-emits *image-filled* strokes. On the GPU/PDF path the texture is confined
/// to the stroke with a `save_layer` + `SrcIn` over the stroke silhouette, which
/// `SkSVGDevice` drops, so the shared renderer skips them on SVG and the
/// compositor draws the image under a `<mask>` built from that silhouette.
///
/// - Solid/center strokes: the silhouette is the stroke geometry, used directly
/// as the mask.
/// - Dotted inner/outer strokes: the silhouette is the boundary-centered dot
/// ring; the image is drawn under that ring mask *and* inside a second group
/// that restricts it to the shape interior (inner) or exterior (outer), since
/// the ring straddles the boundary.
///
/// When no [`ImageProvider`] is available there is nothing to draw.
pub(super) fn render_image_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
scale: f32,
) -> Result<()> {
if shared.images.is_none() {
return Ok(());
}
let matrix = element.centered_transform();
for stroke in element.visible_strokes() {
if !matches!(stroke.fill, Fill::Image(_)) {
continue;
}
match stroke.clip_op() {
// Solid / dotted-center: mask straight to the stroke silhouette.
None => {
if stroke.render_kind(false) == StrokeKind::Outer
&& matches!(element.shape_type, Type::Path(_) | Type::Bool(_))
&& !element.is_open()
{
// Exterior (hide shape interior) ∩ double-width stroke silhouette.
let ext_id = push_inverse_shape_mask(builder, element, &matrix, "smask");
builder.open_group(&format!("mask=\"url(#{ext_id})\""));
let ring_id = push_path_double_stroke_alpha_mask(builder, element, stroke);
builder.open_group(&format!("mask=\"url(#{ring_id})\""));
draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?;
builder.close_group(); // ring
builder.close_group(); // exterior
} else if stroke.render_kind(false) == StrokeKind::Inner
&& matches!(element.shape_type, Type::Path(_) | Type::Bool(_))
&& !element.is_open()
{
// `clip_to_shape` inside a mask fragment serializes without the
// canvas CTM, so restrict to the rotated interior with a native
// `<clipPath>` and draw the double-width stroke unclipped.
let clip_id = push_leaf_clip_path(builder, element, &matrix);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
let mask_id =
builder.push_stroke_alpha_mask(shared, element, stroke, scale, false)?;
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?;
builder.close_group(); // stroke
builder.close_group(); // interior
} else {
let mask_id =
builder.push_stroke_alpha_mask(shared, element, stroke, scale, true)?;
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?;
builder.close_group();
}
}
// Dotted inner/outer: restrict to interior/exterior, then to the ring.
Some(clip_op) => {
if clip_op == skia::ClipOp::Intersect {
let clip_id = push_leaf_clip_path(builder, element, &matrix);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
} else {
let mask_id = push_inverse_shape_mask(builder, element, &matrix, "dmask");
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
}
let ring_id = push_dotted_ring_alpha_mask(builder, element, stroke, &matrix);
builder.open_group(&format!("mask=\"url(#{ring_id})\""));
draw_stroke_image(builder, shared, element, stroke, scale, &matrix)?;
builder.close_group(); // ring
builder.close_group(); // interior/exterior restriction
}
}
}
Ok(())
}
/// Draws an image-filled stroke's texture over its destination rect into the
/// current group (the caller supplies the mask/clip that confines it).
fn draw_stroke_image(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
stroke: &crate::shapes::Stroke,
scale: f32,
matrix: &skia::Matrix,
) -> Result<()> {
let mut renderer = VectorRenderer::new_svg_layer(builder, shared, scale, VectorTarget::Svg);
{
let canvas = renderer.skia_canvas();
canvas.save();
canvas.concat(matrix);
}
renderer.draw_stroke_image(element, stroke)?;
{
let canvas = renderer.skia_canvas();
canvas.restore();
}
Ok(())
}
/// Re-emits the *dotted inner/outer* strokes of a rect/circle. On the GPU/PDF
/// path these stamp a ring of dots centered on the shape boundary and clip it
/// to the shape interior (inner) or exterior (outer) inside a `save_layer` that
/// `SkSVGDevice` drops, so the shared renderer skips them on SVG and the
/// compositor re-emits the same ring of dots here, clipped natively: a
/// `<g clip-path>` (inner) or a `<g mask>` with the inverse-of-shape luminance
/// mask (outer, since SVG `<clipPath>` cannot subtract).
pub(super) fn render_rect_circle_dotted_strokes(
builder: &mut SvgLayerCanvas,
element: &Shape,
) -> Result<()> {
if !matches!(element.shape_type, Type::Rect(_) | Type::Circle) {
return Ok(());
}
let matrix = element.centered_transform();
let svg_attrs = element.svg_attrs.as_ref();
for stroke in element.visible_strokes() {
let Some(clip_op) = stroke.clip_op() else {
continue;
};
// Image-filled dotted strokes are re-emitted by `render_image_strokes`
// (the dots must be filled with the texture, not a solid paint).
if matches!(stroke.fill, Fill::Image(_)) {
continue;
}
let paint = stroke.to_paint(&element.selrect, svg_attrs, true);
if clip_op == skia::ClipOp::Intersect {
let clip_id = push_leaf_clip_path(builder, element, &matrix);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
} else {
let mask_id = push_inverse_shape_mask(builder, element, &matrix, "dmask");
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
}
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
draw_shape_geometry(canvas, element, &paint);
canvas.restore();
}
builder.close_group();
}
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,136 @@
use skia_safe::Canvas;
use crate::error::Result;
use crate::shapes::{Shape, StrokeKind};
use super::document::SvgLayerCanvas;
use super::masks::push_inverse_glyph_mask;
use crate::render::vector::{ExportState, VectorRenderer, VectorTarget};
/// Emits each semi-transparent *center* text stroke as a `<g opacity>` wrapper
/// around the fully-opaque stroke geometry, matching the GPU/PDF opacity-layer
/// result without a `save_layer` (which `SkSVGDevice` would drop). Inner strokes
/// are handled by `render_text_inner_strokes` and outer strokes (any
/// opacity) by `render_text_outer_strokes`.
pub(super) fn render_text_alpha_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
scale: f32,
) -> Result<()> {
let matrix = element.centered_transform();
for stroke in element.visible_strokes() {
if stroke.render_kind(false) != StrokeKind::Center {
continue;
}
let opacity = stroke.fill.opacity();
if opacity >= 1.0 {
// Opaque strokes were already drawn by `render_leaf_content`.
continue;
}
builder.open_group(&format!("opacity=\"{opacity}\""));
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_text_stroke_opaque(element, stroke)?;
canvas.restore();
}
builder.close_group();
}
Ok(())
}
/// Emits each inner text stroke as a `<g clip-path>` (glyph-silhouette clip)
/// wrapping the fully-opaque double-width stroke, plus a `<g opacity>` when the
/// stroke is semi-transparent. Reproduces the GPU/PDF mask + `SrcIn` + `DstOver`
/// inner-stroke composition, which `SkSVGDevice` drops (it lives inside
/// `save_layer`s). Inner strokes vanish from SVG regardless of opacity, so all
/// of them are handled here.
pub(super) fn render_text_inner_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
scale: f32,
) -> Result<()> {
let matrix = element.centered_transform();
for stroke in element.visible_strokes() {
if stroke.render_kind(false) != StrokeKind::Inner {
continue;
}
// Clip path from the opaque glyph silhouette: clipping the double-width
// stroke to the glyph interior keeps only its inner half.
let clip_id = builder.unique("tclip");
{
let canvas = builder.new_fragment();
{
let cv: &Canvas = &*canvas;
cv.concat(&matrix);
let mut renderer = VectorRenderer::new(cv, shared, scale, VectorTarget::Svg);
renderer.draw_text_glyph_silhouette(element)?;
}
builder.finish_clip_path_fragment(&clip_id, canvas);
}
let opacity = stroke.fill.opacity();
let mut attrs = format!("clip-path=\"url(#{clip_id})\"");
if opacity < 1.0 {
attrs.push_str(&format!(" opacity=\"{opacity}\""));
}
builder.open_group(&attrs);
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_text_stroke_opaque(element, stroke)?;
canvas.restore();
}
builder.close_group();
}
Ok(())
}
/// Emits each *outer* text stroke as a `<g mask>` (glyph-exterior mask) wrapping
/// the fully-opaque double-width stroke, plus a `<g opacity>` when the stroke is
/// semi-transparent. The shared renderer draws the outer stroke at double width
/// centered on the glyph outline and relies on a `save_layer` + `Clear` (keep
/// only the outer half) that `SkSVGDevice` drops, so on SVG it is skipped there
/// and re-emitted here masked to the glyph exterior (an inverse-of-glyph
/// luminance mask: white canvas minus the glyphs, since `<clipPath>` cannot
/// subtract), keeping only the outer half and matching the GPU/PDF width.
pub(super) fn render_text_outer_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut ExportState,
element: &Shape,
scale: f32,
) -> Result<()> {
let matrix = element.centered_transform();
for stroke in element.visible_strokes() {
if stroke.render_kind(false) != StrokeKind::Outer {
continue;
}
let mask_id = push_inverse_glyph_mask(builder, shared, element, &matrix, scale)?;
let opacity = stroke.fill.opacity();
let mut attrs = format!("mask=\"url(#{mask_id})\"");
if opacity < 1.0 {
attrs.push_str(&format!(" opacity=\"{opacity}\""));
}
builder.open_group(&attrs);
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, VectorTarget::Svg);
renderer.draw_text_stroke_opaque(element, stroke)?;
canvas.restore();
}
builder.close_group();
}
Ok(())
}

View File

@ -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,
)
}

View File

@ -0,0 +1,34 @@
use std::collections::HashSet;
use skia_safe::textlayout;
use super::fonts::FontStore;
use crate::utils::Browser;
/// Font and browser context for text shaping (paragraph builders, transforms).
pub struct TextShapingCtx<'a> {
pub fonts: &'a FontStore,
pub browser: Browser,
}
impl<'a> TextShapingCtx<'a> {
pub fn new(fonts: &'a FontStore, browser: Browser) -> Self {
Self { fonts, browser }
}
/// Live editor session: GPU `RenderState` fonts and the design browser.
pub fn from_session() -> TextShapingCtx<'static> {
TextShapingCtx {
fonts: crate::get_render_state().fonts(),
browser: Browser::from(crate::globals::get_design_state().current_browser),
}
}
pub fn font_collection(&self) -> &textlayout::FontCollection {
self.fonts.font_collection()
}
pub fn fallback_fonts(&self) -> &HashSet<String> {
self.fonts.get_fallback()
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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(&paragraph_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

View File

@ -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() {

View File

@ -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();

View File

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

View File

@ -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(())
}

View File

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

View File

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

View File

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