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:
Alejandro Alonso 2026-09-01 17:06:50 +02:00 committed by GitHub
parent c7b3a0849f
commit d1a54134bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 496 additions and 24 deletions

View File

@ -121,8 +121,28 @@
(aget shape-id-buffer 3)))))
;; 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
[font-data font-array-buffer emoji? fallback?]
[font-data font-array-buffer font-url emoji? fallback?]
(when (wasm/live?)
(let [font-id-buffer (:family-id-buffer font-data)
size (.-byteLength font-array-buffer)
@ -140,6 +160,7 @@
(:style font-data)
emoji?
fallback?)
(store-font-url font-data font-url)
(clear-font-storage-failure! font-data)
;; Reported after the store call: subscribers react by measuring text.
(rx/push! font-stored-stream (font-data-key font-data))
@ -158,7 +179,8 @@
(fn [request]
{:font-data font-data
:emoji? emoji?
:fallback? (or fallback? (:fallback? request))}))))
:fallback? (or fallback? (:fallback? request))
:font-url font-url}))))
(defn- take-font-fetches!
[font-url]
@ -176,9 +198,9 @@
(report-font-storage-failed! font-data))))
(defn- store-font-fetch!
[body {:keys [font-data emoji? fallback?]}]
[body {:keys [font-data emoji? fallback? font-url]}]
(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?
(report-font-storage-failed! font-data))
stored?)
@ -275,6 +297,7 @@
;; Deferred so consumers, which subscribe after dispatching the sync
;; that lands here, are listening when an already-stored font reports.
(do
(store-font-url font-data uri)
(clear-font-storage-failure! font-data)
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
(fetch-font font-data uri emoji? fallback?)))

View File

@ -56,6 +56,11 @@ pub(crate) fn has_render_state() -> bool {
unsafe { !RENDER_STATE.is_null() }
}
#[inline(always)]
pub(crate) fn has_render_resources() -> bool {
unsafe { !RENDER_RESOURCES.is_null() }
}
#[inline(always)]
pub(crate) fn get_resources() -> &'static mut RenderResources {
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.
fn gpu_init() {
unsafe {

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,133 @@ 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('"', "\\\"")
}
/// 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 {
@ -144,3 +275,37 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr
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());
}
}

View File

@ -81,7 +81,8 @@ impl SvgLayerCanvas {
}
let prefix = format!("f{}_", self.frag_no);
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) {
@ -119,7 +120,7 @@ impl SvgLayerCanvas {
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
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!(
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
@ -236,3 +237,50 @@ fn remap_ids(body: &str, prefix: &str) -> String {
}
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, \""));
}
}

View File

@ -2,14 +2,26 @@
use skia_safe as skia;
use crate::render::RenderResources;
use crate::shapes::{Fill, Frame, Group, Rect, SolidColor, Type};
use crate::globals::TestRenderResourcesGuard;
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::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
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.
pub(super) fn uid(n: u32) -> Uuid {
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 {
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");
String::from_utf8(bytes).expect("utf8 svg")
}

View File

@ -1,13 +1,56 @@
use skia_safe::{self as skia};
use std::collections::HashSet;
use crate::error::Result;
use crate::shapes::Type;
use crate::math::Bounds;
use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{render_leaf_content, VectorRenderer};
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.
///
/// 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
/// `<clipPath>`.
///
/// Special-case re-emission for shadows, layer blur, masks, and deferred
/// strokes is intentionally out of scope for this first cut.
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
/// deferred strokes is intentionally out of scope for this cut.
pub fn render_to_svg(
shared: &mut RenderResources,
id: &Uuid,
@ -42,7 +85,7 @@ pub(crate) fn render_tree_to_svg(
// children (and leaf effects like shadows when those land). Clipped frames
// still resolve to roughly their selrect because `extrect` skips joining
// 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_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 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(&format!(
"<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}\">"
));
if !defs.is_empty() {
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>");
}
@ -72,12 +124,13 @@ pub(crate) fn render_tree_to_svg(
mod document;
mod frames;
mod groups;
mod text;
use document::SvgLayerCanvas;
use frames::render_frame;
use groups::render_group;
use text::render_text_fill;
use crate::shapes::Shape;
use document::effect_attrs;
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
@ -134,13 +187,17 @@ fn render_leaf(
}
{
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
render_leaf_content(&mut renderer, element)?;
canvas.restore();
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, element)?;
} else {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
render_leaf_content(&mut renderer, element)?;
canvas.restore();
}
}
if effects.is_some() {

View File

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

View File

@ -174,3 +174,30 @@ fn exports_an_unclipped_frame_with_overflowing_child() {
);
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);
}

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

View File

@ -395,6 +395,15 @@ fn render_text_on_canvas(
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.
fn paint_text(
canvas: &Canvas,

View File

@ -34,7 +34,11 @@ pub fn get_fallback_fonts() -> &'static HashSet<String> {
}
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)]

View File

@ -1,5 +1,6 @@
use macros::{wasm_error, ToJs};
use crate::error::Error;
use crate::get_resources;
use crate::mem;
use crate::render::FontStore;
@ -55,6 +56,29 @@ 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_resources().fonts.set_source_url(&family.alias(), url);
Ok(())
}
/// 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
/// requests must call this per render so fonts don't accumulate unbounded.