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}"); + } }