mirror of
https://github.com/penpot/penpot.git
synced 2026-09-06 12:09:24 +00:00
✨ Add text and @font-face to WASM SVG export (#11442)
* ✨ Add text and @font-face to WASM SVG export Emit selectable <text> elements and inject @font-face rules that reference the font URLs registered at load time. Text fill bypasses save_layer wrappers that SkSVGDevice drops. Text strokes are deferred to a follow-up PR. * 🐛 Strip trailing commas from Skia SVG text positions Skia's SVG backend appends a trailing comma to per-glyph x/y lists. Firefox rejects the malformed attribute and drops glyph positioning, making exported text vanish or mis-render.
This commit is contained in:
parent
c7b3a0849f
commit
d1a54134bb
@ -121,8 +121,28 @@
|
|||||||
(aget shape-id-buffer 3)))))
|
(aget shape-id-buffer 3)))))
|
||||||
|
|
||||||
;; IMPORTANT: Only TTF fonts can be stored.
|
;; IMPORTANT: Only TTF fonts can be stored.
|
||||||
|
(defn- store-font-url
|
||||||
|
[font-data font-url]
|
||||||
|
(when (and (wasm/live?) (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
|
(defn- store-font-buffer
|
||||||
[font-data font-array-buffer emoji? fallback?]
|
[font-data font-array-buffer font-url emoji? fallback?]
|
||||||
(when (wasm/live?)
|
(when (wasm/live?)
|
||||||
(let [font-id-buffer (:family-id-buffer font-data)
|
(let [font-id-buffer (:family-id-buffer font-data)
|
||||||
size (.-byteLength font-array-buffer)
|
size (.-byteLength font-array-buffer)
|
||||||
@ -140,6 +160,7 @@
|
|||||||
(:style font-data)
|
(:style font-data)
|
||||||
emoji?
|
emoji?
|
||||||
fallback?)
|
fallback?)
|
||||||
|
(store-font-url font-data font-url)
|
||||||
(clear-font-storage-failure! font-data)
|
(clear-font-storage-failure! font-data)
|
||||||
;; Reported after the store call: subscribers react by measuring text.
|
;; Reported after the store call: subscribers react by measuring text.
|
||||||
(rx/push! font-stored-stream (font-data-key font-data))
|
(rx/push! font-stored-stream (font-data-key font-data))
|
||||||
@ -158,7 +179,8 @@
|
|||||||
(fn [request]
|
(fn [request]
|
||||||
{:font-data font-data
|
{:font-data font-data
|
||||||
:emoji? emoji?
|
:emoji? emoji?
|
||||||
:fallback? (or fallback? (:fallback? request))}))))
|
:fallback? (or fallback? (:fallback? request))
|
||||||
|
:font-url font-url}))))
|
||||||
|
|
||||||
(defn- take-font-fetches!
|
(defn- take-font-fetches!
|
||||||
[font-url]
|
[font-url]
|
||||||
@ -176,9 +198,9 @@
|
|||||||
(report-font-storage-failed! font-data))))
|
(report-font-storage-failed! font-data))))
|
||||||
|
|
||||||
(defn- store-font-fetch!
|
(defn- store-font-fetch!
|
||||||
[body {:keys [font-data emoji? fallback?]}]
|
[body {:keys [font-data emoji? fallback? font-url]}]
|
||||||
(try
|
(try
|
||||||
(let [stored? (store-font-buffer font-data body emoji? fallback?)]
|
(let [stored? (store-font-buffer font-data body font-url emoji? fallback?)]
|
||||||
(when-not stored?
|
(when-not stored?
|
||||||
(report-font-storage-failed! font-data))
|
(report-font-storage-failed! font-data))
|
||||||
stored?)
|
stored?)
|
||||||
@ -275,6 +297,7 @@
|
|||||||
;; Deferred so consumers, which subscribe after dispatching the sync
|
;; Deferred so consumers, which subscribe after dispatching the sync
|
||||||
;; that lands here, are listening when an already-stored font reports.
|
;; that lands here, are listening when an already-stored font reports.
|
||||||
(do
|
(do
|
||||||
|
(store-font-url font-data uri)
|
||||||
(clear-font-storage-failure! font-data)
|
(clear-font-storage-failure! font-data)
|
||||||
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
|
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
|
||||||
(fetch-font font-data uri emoji? fallback?)))
|
(fetch-font font-data uri emoji? fallback?)))
|
||||||
|
|||||||
@ -56,6 +56,11 @@ pub(crate) fn has_render_state() -> bool {
|
|||||||
unsafe { !RENDER_STATE.is_null() }
|
unsafe { !RENDER_STATE.is_null() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub(crate) fn has_render_resources() -> bool {
|
||||||
|
unsafe { !RENDER_RESOURCES.is_null() }
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub(crate) fn get_resources() -> &'static mut RenderResources {
|
pub(crate) fn get_resources() -> &'static mut RenderResources {
|
||||||
unsafe {
|
unsafe {
|
||||||
@ -115,6 +120,28 @@ macro_rules! with_current_shape {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scoped override of the global render resources pointer for unit tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) struct TestRenderResourcesGuard {
|
||||||
|
prev: *mut RenderResources,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl TestRenderResourcesGuard {
|
||||||
|
pub(crate) fn install(resources: &mut RenderResources) -> Self {
|
||||||
|
let prev = unsafe { RENDER_RESOURCES };
|
||||||
|
unsafe { RENDER_RESOURCES = resources as *mut _ };
|
||||||
|
Self { prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Drop for TestRenderResourcesGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { RENDER_RESOURCES = self.prev };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes GPUState.
|
/// Initializes GPUState.
|
||||||
fn gpu_init() {
|
fn gpu_init() {
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use skia_safe::{self as skia, textlayout, Font, FontMgr};
|
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::error::{Error, Result};
|
||||||
use crate::shapes::{FontFamily, FontStyle};
|
use crate::shapes::{FontFamily, FontStyle};
|
||||||
@ -26,6 +26,9 @@ pub struct FontStore {
|
|||||||
debug_font: Font,
|
debug_font: Font,
|
||||||
ui_font: Font,
|
ui_font: Font,
|
||||||
fallback_fonts: HashSet<String>,
|
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 {
|
impl FontStore {
|
||||||
@ -55,6 +58,7 @@ impl FontStore {
|
|||||||
debug_font,
|
debug_font,
|
||||||
ui_font,
|
ui_font,
|
||||||
fallback_fonts: HashSet::new(),
|
fallback_fonts: HashSet::new(),
|
||||||
|
source_urls: HashMap::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +135,133 @@ impl FontStore {
|
|||||||
pub fn get_emoji_font(&self, _size: f32) -> Option<Font> {
|
pub fn get_emoji_font(&self, _size: f32) -> Option<Font> {
|
||||||
None
|
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('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider {
|
||||||
@ -144,3 +275,37 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr
|
|||||||
|
|
||||||
font_provider
|
font_provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -81,7 +81,8 @@ impl SvgLayerCanvas {
|
|||||||
}
|
}
|
||||||
let prefix = format!("f{}_", self.frag_no);
|
let prefix = format!("f{}_", self.frag_no);
|
||||||
self.frag_no += 1;
|
self.frag_no += 1;
|
||||||
self.out.push_str(&remap_ids(inner, &prefix));
|
self.out
|
||||||
|
.push_str(&sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn open_group(&mut self, attrs: &str) {
|
pub(super) fn open_group(&mut self, attrs: &str) {
|
||||||
@ -119,7 +120,7 @@ impl SvgLayerCanvas {
|
|||||||
let inner = extract_inner_svg(&doc);
|
let inner = extract_inner_svg(&doc);
|
||||||
let prefix = format!("f{}_", self.frag_no);
|
let prefix = format!("f{}_", self.frag_no);
|
||||||
self.frag_no += 1;
|
self.frag_no += 1;
|
||||||
let geometry = remap_ids(inner, &prefix);
|
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix));
|
||||||
self.defs.push_str(&format!(
|
self.defs.push_str(&format!(
|
||||||
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
|
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
|
||||||
));
|
));
|
||||||
@ -236,3 +237,50 @@ fn remap_ids(body: &str, prefix: &str) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Skia's SVG backend appends a trailing comma to list-valued `<text>` attrs
|
||||||
|
/// (`x`, `y`, `dx`, `dy`). Firefox rejects the malformed list and drops the
|
||||||
|
/// glyph positioning (text vanishes or mis-renders).
|
||||||
|
fn sanitize_skia_svg_fragment(body: &str) -> String {
|
||||||
|
const LIST_ATTRS: [&str; 4] = ["x=\"", "y=\"", "dx=\"", "dy=\""];
|
||||||
|
let mut out = body.to_string();
|
||||||
|
|
||||||
|
for attr in LIST_ATTRS {
|
||||||
|
let mut search_from = 0;
|
||||||
|
while let Some(rel) = out[search_from..].find(attr) {
|
||||||
|
let value_start = search_from + rel + attr.len();
|
||||||
|
let Some(end_rel) = out[value_start..].find('"') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let value_end = value_start + end_rel;
|
||||||
|
let trimmed_len = out[value_start..value_end]
|
||||||
|
.trim_end()
|
||||||
|
.trim_end_matches(',')
|
||||||
|
.len();
|
||||||
|
if trimmed_len != value_end - value_start {
|
||||||
|
let trimmed = out[value_start..value_start + trimmed_len].to_string();
|
||||||
|
out.replace_range(value_start..value_end, &trimmed);
|
||||||
|
search_from = value_start + trimmed_len + 1;
|
||||||
|
} else {
|
||||||
|
search_from = value_end + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::sanitize_skia_svg_fragment;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_trailing_comma_from_text_position_lists() {
|
||||||
|
let input = r#"<text x="1119, 1374.8594, 1584.332, " y="402, ">asd</text>"#;
|
||||||
|
let out = sanitize_skia_svg_fragment(input);
|
||||||
|
assert!(out.contains(r#"x="1119, 1374.8594, 1584.332""#));
|
||||||
|
assert!(out.contains(r#"y="402""#));
|
||||||
|
assert!(!out.contains("1584.332, \""));
|
||||||
|
assert!(!out.contains("402, \""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,26 @@
|
|||||||
|
|
||||||
use skia_safe as skia;
|
use skia_safe as skia;
|
||||||
|
|
||||||
use crate::render::RenderResources;
|
use crate::globals::TestRenderResourcesGuard;
|
||||||
use crate::shapes::{Fill, Frame, Group, Rect, SolidColor, Type};
|
use crate::render::{FontStore, RenderResources};
|
||||||
|
use crate::shapes::{
|
||||||
|
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign,
|
||||||
|
TextContent, TextDirection, TextSpan, Type,
|
||||||
|
};
|
||||||
use crate::state::ShapesPool;
|
use crate::state::ShapesPool;
|
||||||
use crate::utils::uuid_from_u32_quartet;
|
use crate::utils::uuid_from_u32_quartet;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
use super::render_tree_to_svg;
|
use super::render_tree_to_svg;
|
||||||
|
|
||||||
|
/// Font URL referenced in exported SVG `@font-face` rules.
|
||||||
|
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/// Deterministic UUID from a small integer, keeping snapshots stable.
|
/// Deterministic UUID from a small integer, keeping snapshots stable.
|
||||||
pub(super) fn uid(n: u32) -> Uuid {
|
pub(super) fn uid(n: u32) -> Uuid {
|
||||||
uuid_from_u32_quartet(0, 0, 0, n)
|
uuid_from_u32_quartet(0, 0, 0, n)
|
||||||
@ -64,8 +76,55 @@ pub(super) fn add_group(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a single-line text shape using the embedded default font.
|
||||||
|
pub(super) fn add_solid_text(
|
||||||
|
pool: &mut ShapesPool,
|
||||||
|
id: Uuid,
|
||||||
|
(l, t, r, b): (f32, f32, f32, f32),
|
||||||
|
text: &str,
|
||||||
|
font_size: f32,
|
||||||
|
fill: skia::Color,
|
||||||
|
) {
|
||||||
|
let bounds = skia::Rect::from_ltrb(l, t, r, b);
|
||||||
|
let mut content = TextContent::new(bounds, GrowType::Fixed);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
|
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
|
||||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||||
|
register_test_font_urls(&mut resources.fonts);
|
||||||
|
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||||
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
|
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
|
||||||
String::from_utf8(bytes).expect("utf8 svg")
|
String::from_utf8(bytes).expect("utf8 svg")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,56 @@
|
|||||||
use skia_safe::{self as skia};
|
use skia_safe::{self as skia};
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::shapes::Type;
|
use crate::math::Bounds;
|
||||||
|
use crate::shapes::{Shape, Type};
|
||||||
use crate::state::ShapesPoolRef;
|
use crate::state::ShapesPoolRef;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
use super::vector::{render_leaf_content, VectorRenderer};
|
use super::vector::{render_leaf_content, VectorRenderer};
|
||||||
use super::RenderResources;
|
use super::RenderResources;
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds for the root `<svg>` width, height, and viewBox.
|
||||||
|
///
|
||||||
|
/// Text uses [`Shape::layer_bounds`]: glyph metrics, at least the `selrect`, and
|
||||||
|
/// room for strokes/shadows/blur. Other shapes use `extrect` for overflow.
|
||||||
|
fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect {
|
||||||
|
if matches!(shape.shape_type, Type::Text(_)) {
|
||||||
|
let mut bounds = Bounds::from_rect(&shape.layer_bounds());
|
||||||
|
if !shape.transform.is_identity() {
|
||||||
|
let mut matrix = shape.transform;
|
||||||
|
let center = shape.center();
|
||||||
|
matrix.post_translate(center);
|
||||||
|
matrix.pre_translate(-center);
|
||||||
|
bounds.transform_mut(&matrix);
|
||||||
|
}
|
||||||
|
bounds.to_rect()
|
||||||
|
} else {
|
||||||
|
shape.extrect(tree, scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a shape tree to an SVG document and returns the raw SVG bytes.
|
/// Renders a shape tree to an SVG document and returns the raw SVG bytes.
|
||||||
///
|
///
|
||||||
/// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted
|
/// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted
|
||||||
@ -16,8 +59,8 @@ use super::RenderResources;
|
|||||||
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
|
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
|
||||||
/// `<clipPath>`.
|
/// `<clipPath>`.
|
||||||
///
|
///
|
||||||
/// Special-case re-emission for shadows, layer blur, masks, and deferred
|
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
|
||||||
/// strokes is intentionally out of scope for this first cut.
|
/// deferred strokes is intentionally out of scope for this cut.
|
||||||
pub fn render_to_svg(
|
pub fn render_to_svg(
|
||||||
shared: &mut RenderResources,
|
shared: &mut RenderResources,
|
||||||
id: &Uuid,
|
id: &Uuid,
|
||||||
@ -42,7 +85,7 @@ pub(crate) fn render_tree_to_svg(
|
|||||||
// children (and leaf effects like shadows when those land). Clipped frames
|
// children (and leaf effects like shadows when those land). Clipped frames
|
||||||
// still resolve to roughly their selrect because `extrect` skips joining
|
// still resolve to roughly their selrect because `extrect` skips joining
|
||||||
// children when `clip_content` is on.
|
// children when `clip_content` is on.
|
||||||
let bounds = shape.extrect(tree, scale);
|
let bounds = svg_page_bounds(shape, tree, scale);
|
||||||
|
|
||||||
let page_w = bounds.width() * scale;
|
let page_w = bounds.width() * scale;
|
||||||
let page_h = bounds.height() * scale;
|
let page_h = bounds.height() * scale;
|
||||||
@ -50,15 +93,24 @@ pub(crate) fn render_tree_to_svg(
|
|||||||
|
|
||||||
let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?;
|
let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?;
|
||||||
|
|
||||||
let mut out = String::with_capacity(body.len() + defs.len() + 256);
|
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("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n");
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||||
width=\"{page_w}\" height=\"{page_h}\" viewBox=\"0 0 {page_w} {page_h}\">"
|
width=\"{page_w}\" height=\"{page_h}\" viewBox=\"0 0 {page_w} {page_h}\">"
|
||||||
));
|
));
|
||||||
|
|
||||||
if !defs.is_empty() {
|
if !font_css.is_empty() || !defs.is_empty() {
|
||||||
out.push_str("<defs>");
|
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("</defs>");
|
out.push_str("</defs>");
|
||||||
}
|
}
|
||||||
@ -72,12 +124,13 @@ pub(crate) fn render_tree_to_svg(
|
|||||||
mod document;
|
mod document;
|
||||||
mod frames;
|
mod frames;
|
||||||
mod groups;
|
mod groups;
|
||||||
|
mod text;
|
||||||
|
|
||||||
use document::SvgLayerCanvas;
|
use document::SvgLayerCanvas;
|
||||||
use frames::render_frame;
|
use frames::render_frame;
|
||||||
use groups::render_group;
|
use groups::render_group;
|
||||||
|
use text::render_text_fill;
|
||||||
|
|
||||||
use crate::shapes::Shape;
|
|
||||||
use document::effect_attrs;
|
use document::effect_attrs;
|
||||||
|
|
||||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||||
@ -134,13 +187,17 @@ fn render_leaf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let matrix = element.centered_transform();
|
if matches!(element.shape_type, Type::Text(_)) {
|
||||||
let canvas = builder.canvas();
|
render_text_fill(builder, element)?;
|
||||||
canvas.save();
|
} else {
|
||||||
canvas.concat(&matrix);
|
let matrix = element.centered_transform();
|
||||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
let canvas = builder.canvas();
|
||||||
render_leaf_content(&mut renderer, element)?;
|
canvas.save();
|
||||||
canvas.restore();
|
canvas.concat(&matrix);
|
||||||
|
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||||
|
render_leaf_content(&mut renderer, element)?;
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if effects.is_some() {
|
if effects.is_some() {
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
source: src/render/svg/tests.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="560" height="240" viewBox="0 0 560 240"><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" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
|
||||||
|
HOLA
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
@ -174,3 +174,30 @@ fn exports_an_unclipped_frame_with_overflowing_child() {
|
|||||||
);
|
);
|
||||||
insta::assert_snapshot!(svg);
|
insta::assert_snapshot!(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exports_solid_text_with_font_face() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let id = uid(1);
|
||||||
|
add_solid_text(
|
||||||
|
&mut pool,
|
||||||
|
id,
|
||||||
|
(0.0, 0.0, 560.0, 240.0),
|
||||||
|
"HOLA",
|
||||||
|
200.0,
|
||||||
|
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
|
||||||
|
);
|
||||||
|
|
||||||
|
let svg = render(&pool, id);
|
||||||
|
assert!(svg.contains("<text"), "text glyphs must be present: {svg}");
|
||||||
|
assert!(
|
||||||
|
svg.contains("@font-face") && svg.contains(TEST_FONT_URL),
|
||||||
|
"missing @font-face for registered font URL: {svg}"
|
||||||
|
);
|
||||||
|
// Fixed-size text exports at the selrect, not tight glyph bounds.
|
||||||
|
assert!(
|
||||||
|
svg.contains("width=\"560\" height=\"240\""),
|
||||||
|
"fixed text should export at selrect size: {svg}"
|
||||||
|
);
|
||||||
|
insta::assert_snapshot!(svg);
|
||||||
|
}
|
||||||
|
|||||||
19
render-wasm/src/render/svg/text.rs
Normal file
19
render-wasm/src/render/svg/text.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::shapes::Shape;
|
||||||
|
|
||||||
|
use super::document::SvgLayerCanvas;
|
||||||
|
use crate::render::text;
|
||||||
|
|
||||||
|
/// Emits a text shape's fill as native `<text>` elements.
|
||||||
|
///
|
||||||
|
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
|
||||||
|
/// silently drops. Text strokes are handled separately in a later PR.
|
||||||
|
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
|
||||||
|
let matrix = element.centered_transform();
|
||||||
|
let canvas = builder.canvas();
|
||||||
|
canvas.save();
|
||||||
|
canvas.concat(&matrix);
|
||||||
|
text::paint_text_fill(canvas, element);
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -395,6 +395,15 @@ fn render_text_on_canvas(
|
|||||||
canvas.restore();
|
canvas.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
|
||||||
|
/// `SkSVGDevice` would drop.
|
||||||
|
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
|
||||||
|
let text_content = shape.get_text_content();
|
||||||
|
let text_content = text_content.new_bounds(shape.selrect());
|
||||||
|
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||||
|
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Lays out and paints paragraph builders without any layer management.
|
/// Lays out and paints paragraph builders without any layer management.
|
||||||
fn paint_text(
|
fn paint_text(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
|
|||||||
@ -34,7 +34,11 @@ pub fn get_fallback_fonts() -> &'static HashSet<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_font_collection() -> &'static FontCollection {
|
pub fn get_font_collection() -> &'static FontCollection {
|
||||||
with_state!(state, { state.font_collection() })
|
if crate::globals::has_render_resources() {
|
||||||
|
get_resources().fonts.font_collection()
|
||||||
|
} else {
|
||||||
|
with_state!(state, { state.font_collection() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use macros::{wasm_error, ToJs};
|
use macros::{wasm_error, ToJs};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
use crate::get_resources;
|
use crate::get_resources;
|
||||||
use crate::mem;
|
use crate::mem;
|
||||||
use crate::render::FontStore;
|
use crate::render::FontStore;
|
||||||
@ -55,6 +56,29 @@ pub extern "C" fn store_font(
|
|||||||
Ok(())
|
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_resources().fonts.set_source_url(&family.alias(), url);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resets the font store to its default state, dropping every font uploaded via
|
/// Resets the font store to its default state, dropping every font uploaded via
|
||||||
/// `store_font`. A headless host that reuses a single WASM instance across
|
/// `store_font`. A headless host that reuses a single WASM instance across
|
||||||
/// requests must call this per render so fonts don't accumulate unbounded.
|
/// requests must call this per render so fonts don't accumulate unbounded.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user