mirror of
https://github.com/penpot/penpot.git
synced 2026-09-02 18:18:41 +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)))))
|
||||
|
||||
;; 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?)))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, \""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user