From 6b8a1f1386633ebc016386ba857a9aa58c546f3f Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 9 Jul 2026 07:10:55 -0400 Subject: [PATCH] SVG export: emit text layers as real glyph outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG export silently dropped Text layers (they fell through the layer_to_svg catch-all) while the dialog implied only raster/video/effect were excluded, so title/caption text vanished from a "lossless" export with a success message. Emit text as actual glyph-outline s: lay the text out with the same parley path the renderer uses, then extract each positioned glyph's outline with skrifa (an OutlinePen that maps points into document space — Y flip, synthetic-italic skew, variable-font normalized coords). Result is font-independent and needs no /@font-face. Vello rasterizes glyphs on the GPU and doesn't expose the path, but the skrifa outline API it uses is directly callable and parley's glyph IDs are real font GIDs, so the outlines match. Synthetic bold is not applied (rare). Adds a skrifa dep pinned to parley's 0.43. --- lightningbeam-ui/Cargo.lock | 1 + .../lightningbeam-core/Cargo.toml | 3 + .../lightningbeam-core/src/svg_export.rs | 184 +++++++++++++++++- 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 48824a8..efbdbeb 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3615,6 +3615,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "skrifa 0.43.2", "tiny-skia", "uuid", "vello", diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 2a457c7..11b15bc 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -20,6 +20,9 @@ vello = { workspace = true } # Text layout/shaping (text layers) parley = { workspace = true } +# Glyph outline extraction for lossless text→SVG export. Pinned to the version parley resolves +# (0.43) so glyph IDs / normalized coords from parley layouts match this outliner. +skrifa = "0.43" # Image decoding for image fills image = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs index 008bc4e..cccf91d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs @@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph}; use kurbo::{BezPath, PathEl, Rect, Shape}; /// Serialize the document's **vector** content to a standalone SVG string, at document time `time`. -/// Vector layers (and groups of them) only — raster/video/audio/effect layers are skipped (a later -/// pass can rasterize them to ``). Animation is a single static frame at `time`. +/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/ +/// effect layers are skipped (a later pass can rasterize them to ``). Animation is a single +/// static frame at `time`. pub fn document_to_svg(document: &Document, time: f64) -> String { let (w, h) = (document.width, document.height); let mut defs = String::new(); @@ -300,11 +301,136 @@ fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut Str body.push_str(""); } } + AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body), // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. _ => {} } } +/// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string. +/// +/// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each +/// point into document space: `x = gx + px + skew·py`, `y = gy − py` (Y flips, `skew` applies any +/// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position. +struct SvgOutlinePen<'a> { + gx: f64, + gy: f64, + skew: f64, + d: &'a mut String, +} + +impl<'a> SvgOutlinePen<'a> { + fn map(&self, px: f32, py: f32) -> (f64, f64) { + let (px, py) = (px as f64, py as f64); + (self.gx + px + self.skew * py, self.gy - py) + } +} + +impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + let (x, y) = self.map(x, y); + self.d.push_str(&format!("M{x:.2} {y:.2}")); + } + fn line_to(&mut self, x: f32, y: f32) { + let (x, y) = self.map(x, y); + self.d.push_str(&format!("L{x:.2} {y:.2}")); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + let (cx, cy) = self.map(cx, cy); + let (x, y) = self.map(x, y); + self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}")); + } + fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) { + let (c0x, c0y) = self.map(c0x, c0y); + let (c1x, c1y) = self.map(c1x, c1y); + let (x, y) = self.map(x, y); + self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}")); + } + fn close(&mut self) { + self.d.push('Z'); + } +} + +/// Append a text layer's glyphs to `body` as a single filled `` of real glyph outlines +/// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the +/// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and +/// synthetic-italic skew are honored; synthetic bold is not (rare). +fn text_layer_to_svg( + tl: &crate::text_layer::TextLayer, + time: f64, + parent_opacity: f64, + body: &mut String, +) { + use skrifa::MetadataProvider; + + if !tl.layer.visible { + return; + } + let content = tl.content_at(time); + if content.text.is_empty() { + return; + } + + let (ox, oy) = (tl.box_origin.x, tl.box_origin.y); + let mut d = String::new(); + + crate::fonts::with_layout(content, tl.box_width as f32, |layout| { + for line in layout.lines() { + for item in line.items() { + let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + let run = glyph_run.run(); + let font = run.font(); + let font_size = run.font_size(); + let skew = run + .synthesis() + .skew() + .map(|angle| (angle as f64).to_radians().tan()) + .unwrap_or(0.0); + + let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else { + continue; + }; + let outlines = font_ref.outline_glyphs(); + + // Variable-font axis position for this run (empty for static fonts). + let coords: Vec = run + .normalized_coords() + .iter() + .map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)) + .collect(); + let location = skrifa::instance::LocationRef::new(&coords); + let size = skrifa::instance::Size::new(font_size); + + for g in glyph_run.positioned_glyphs() { + let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else { + continue; + }; + let mut pen = SvgOutlinePen { + gx: ox + g.x as f64, + gy: oy + g.y as f64, + skew, + d: &mut d, + }; + let settings = skrifa::outline::DrawSettings::unhinted(size, location); + let _ = glyph.draw(settings, &mut pen); + } + } + } + }); + + if d.is_empty() { + return; + } + + let [r, g, b, a] = content.color; + let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8; + let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0); + body.push_str(&format!( + r#""#, + to_u8(r), to_u8(g), to_u8(b), fill_opacity, d + )); +} + /// Emit a vector graph's fills (``) and stroked edges (``) into `body`, /// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform). fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) { @@ -488,4 +614,58 @@ mod export_tests { // 1 fill path + 3 stroked edges = 4 elements. assert_eq!(body.matches(" 80, "suspiciously short path: {body}"); + } + + #[test] + fn empty_text_layer_emits_nothing() { + use crate::text_layer::TextLayer; + let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set + let mut body = String::new(); + text_layer_to_svg(&tl, 0.0, 1.0, &mut body); + assert!(body.is_empty(), "empty text should emit nothing: {body}"); + } }