SVG export: emit text layers as real glyph outlines
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 <path>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 <text>/@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.
This commit is contained in:
parent
53ffb7d528
commit
6b8a1f1386
|
|
@ -3615,6 +3615,7 @@ dependencies = [
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"skrifa 0.43.2",
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vello",
|
"vello",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ vello = { workspace = true }
|
||||||
|
|
||||||
# Text layout/shaping (text layers)
|
# Text layout/shaping (text layers)
|
||||||
parley = { workspace = true }
|
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 decoding for image fills
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph};
|
||||||
use kurbo::{BezPath, PathEl, Rect, Shape};
|
use kurbo::{BezPath, PathEl, Rect, Shape};
|
||||||
|
|
||||||
/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`.
|
/// 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
|
/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/
|
||||||
/// pass can rasterize them to `<image>`). Animation is a single static frame at `time`.
|
/// effect layers are skipped (a later pass can rasterize them to `<image>`). Animation is a single
|
||||||
|
/// static frame at `time`.
|
||||||
pub fn document_to_svg(document: &Document, time: f64) -> String {
|
pub fn document_to_svg(document: &Document, time: f64) -> String {
|
||||||
let (w, h) = (document.width, document.height);
|
let (w, h) = (document.width, document.height);
|
||||||
let mut defs = String::new();
|
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("</g>");
|
body.push_str("</g>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body),
|
||||||
// Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass.
|
// 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 `<path>` 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<skrifa::instance::NormalizedCoord> = 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#"<path fill="rgb({},{},{})" fill-opacity="{:.4}" fill-rule="nonzero" d="{}"/>"#,
|
||||||
|
to_u8(r), to_u8(g), to_u8(b), fill_opacity, d
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`,
|
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`,
|
||||||
/// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform).
|
/// 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) {
|
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 <path> elements.
|
// 1 fill path + 3 stroked edges = 4 <path> elements.
|
||||||
assert_eq!(body.matches("<path").count(), 4, "{body}");
|
assert_eq!(body.matches("<path").count(), 4, "{body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outline_pen_maps_yflip_and_skew() {
|
||||||
|
use skrifa::outline::OutlinePen;
|
||||||
|
let mut d = String::new();
|
||||||
|
{
|
||||||
|
let mut pen = SvgOutlinePen { gx: 10.0, gy: 100.0, skew: 0.0, d: &mut d };
|
||||||
|
pen.move_to(0.0, 0.0); // baseline origin → (10, 100)
|
||||||
|
pen.line_to(5.0, 20.0); // 20 up → y = 100 − 20 = 80
|
||||||
|
pen.close();
|
||||||
|
}
|
||||||
|
assert!(d.contains("M10.00 100.00"), "d={d}");
|
||||||
|
assert!(d.contains("L15.00 80.00"), "d={d}");
|
||||||
|
assert!(d.ends_with('Z'));
|
||||||
|
|
||||||
|
// Synthetic-italic skew shifts x right in proportion to height.
|
||||||
|
let mut d2 = String::new();
|
||||||
|
{
|
||||||
|
let mut pen = SvgOutlinePen { gx: 0.0, gy: 0.0, skew: 0.5, d: &mut d2 };
|
||||||
|
pen.move_to(0.0, 10.0); // x = 0 + 0.5·10 = 5, y = −10
|
||||||
|
}
|
||||||
|
assert!(d2.contains("M5.00 -10.00"), "d={d2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_layer_emits_real_glyph_outlines() {
|
||||||
|
use crate::text_layer::TextLayer;
|
||||||
|
|
||||||
|
let mut tl = TextLayer::new("t", Point::new(20.0, 60.0));
|
||||||
|
tl.content.text = "Hi".to_string();
|
||||||
|
tl.content.font_size = 48.0;
|
||||||
|
tl.content.color = [1.0, 0.0, 0.0, 1.0];
|
||||||
|
|
||||||
|
let mut body = String::new();
|
||||||
|
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
|
||||||
|
|
||||||
|
// Bundled fonts guarantee glyphs → a filled path with actual outline segments.
|
||||||
|
assert!(body.contains("<path"), "no path emitted: {body}");
|
||||||
|
assert!(body.contains(r#"fill="rgb(255,0,0)""#), "wrong fill: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains('C') || body.contains('Q') || body.contains('L'),
|
||||||
|
"path has no outline segments: {body}"
|
||||||
|
);
|
||||||
|
assert!(body.len() > 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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue