From d1a54134bb473d30b51cb5d1cb71628bbdbf6abb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 1 Sep 2026 17:06:50 +0200 Subject: [PATCH] :sparkles: Add text and @font-face to WASM SVG export (#11442) * :sparkles: Add text and @font-face to WASM SVG export Emit selectable 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. * :bug: 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. --- frontend/src/app/render_wasm/api/fonts.cljs | 31 +++- render-wasm/src/globals.rs | 27 +++ render-wasm/src/render/fonts.rs | 167 +++++++++++++++++- render-wasm/src/render/svg/document.rs | 52 +++++- render-wasm/src/render/svg/fixtures.rs | 63 ++++++- render-wasm/src/render/svg/mod.rs | 85 +++++++-- ...ts__exports_solid_text_with_font_face.snap | 10 ++ render-wasm/src/render/svg/tests.rs | 27 +++ render-wasm/src/render/svg/text.rs | 19 ++ render-wasm/src/render/text.rs | 9 + render-wasm/src/utils.rs | 6 +- render-wasm/src/wasm/fonts.rs | 24 +++ 12 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap create mode 100644 render-wasm/src/render/svg/text.rs diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c44cc81fa5..0b438c5e20 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -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?))) diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index b1de8c9271..b7956075a6 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -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 { diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..5df5403bc4 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -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, + /// Source URL registered when the font was fetched (SVG export references + /// this in `@font-face` rules). + source_urls: HashMap, } 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 { 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 { + let mut seen: HashSet = 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 `` 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 `` 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 `` 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()); + } +} diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 5724b61677..083c08f920 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -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!( "{geometry}" )); @@ -236,3 +237,50 @@ fn remap_ids(body: &str, prefix: &str) -> String { } out } + +/// Skia's SVG backend appends a trailing comma to list-valued `` 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#"asd"#; + 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, \"")); + } +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index c4cda3cff7..1359cbd29a 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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") } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 57207fbe22..b7b6e9c29a 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -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) { + 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 `` 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 `` wrappers. Frame `clip content` uses a native /// ``. /// -/// 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("\n"); out.push_str(&format!( "" )); - if !defs.is_empty() { + if !font_css.is_empty() || !defs.is_empty() { out.push_str(""); + if !font_css.is_empty() { + out.push_str(&format!( + "" + )); + } out.push_str(&defs); out.push_str(""); } @@ -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() { diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap new file mode 100644 index 0000000000..d1f4dd9b15 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 388ef2f09b..4076f27719 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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("` 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(()) +} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index e027aebd21..291768f8d9 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -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, diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 2301846c10..e153e03b97 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -34,7 +34,11 @@ pub fn get_fallback_fonts() -> &'static HashSet { } 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)] diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 112d523035..f3ad1d4189 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -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.