From 9fa99f8594f91f33c3bf818154e3d3aace1f3cdd Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 15 Oct 2026 17:18:44 +0200
Subject: [PATCH] Keep per-character text fills in SVG export (#429)
---
crates/svg/src/lib.rs | 68 +++++++++++++++++++++++++++++++++++++++++
crates/svg/src/tests.rs | 33 ++++++++++++++++++++
2 files changed, 101 insertions(+)
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 9f0b9f83..a6813017 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -431,8 +431,19 @@ impl W<'_> {
};
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
+ let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
if let Some(t) = &live_text {
self.body.push_str(t);
+ } else if let Some(runs) = &run_paths {
+ // Character ranges with their own fills: one path per style, like the canvas.
+ for (path, fills) in runs {
+ for p in fills {
+ if let Some(a) = self.paint_attr(p, w, h) {
+ let el = format!("", matrix(xf), path_d(path));
+ self.body.push_str(&clipped(&paint_clip, el));
+ }
+ }
+ }
} else if let Some((path, rule)) = &outline
&& closed
{
@@ -559,6 +570,36 @@ impl W<'_> {
if let Some((layout, dy)) = figcraft_text::layout_node(n) {
gxf = xf * Affine::translate((0.0, dy));
for line in &layout.lines {
+ let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
+ let run_fills = !line.rtl
+ && !glyphs.iter().any(|g| g.rtl)
+ && glyphs.iter().any(|g| layout.styles.get(g.style).is_some_and(|s| s.fills.is_some()));
+ if run_fills {
+ // One tspan per run of glyphs in the same style, each in its own fill.
+ let mut i = 0;
+ let mut start = line.char_start;
+ while i < glyphs.len() {
+ let style = glyphs[i].style;
+ let mut j = i + 1;
+ while j < glyphs.len() && glyphs[j].style == style {
+ j += 1;
+ }
+ let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
+ let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
+ let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
+ let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
+ Some(fills) => {
+ let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
+ format!(" {a}")
+ }
+ None => String::new(),
+ };
+ let _ = write!(spans, "{}", num(glyphs[i].x), num(glyphs[i].y), esc(&text));
+ start = end;
+ i = j;
+ }
+ continue;
+ }
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
@@ -979,6 +1020,33 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
))
}
+/// Text layer `n`'s glyph outlines grouped by style, each with the fills it paints in (its own, else
+/// the layer's), in layer space. `None` unless some character range has fills of its own.
+fn text_run_paths(n: &Node) -> Option)>> {
+ let NodeKind::Text(_) = &n.kind else { return None };
+ let (layout, dy) = figcraft_text::layout_node(n)?;
+ if !layout.styles.iter().any(|s| s.fills.is_some()) {
+ return None;
+ }
+ let mut by_style: std::collections::BTreeMap = std::collections::BTreeMap::new();
+ for g in &layout.glyphs {
+ by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
+ }
+ for d in &layout.decorations {
+ by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
+ }
+ Some(
+ by_style
+ .into_iter()
+ .map(|(k, mut path)| {
+ path.apply_affine(Affine::translate((0.0, dy)));
+ let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone());
+ (path, fills)
+ })
+ .collect(),
+ )
+}
+
#[cfg(test)]
mod depth_tests;
#[cfg(test)]
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index 360b95ca..b15cd999 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -82,3 +82,36 @@ fn nested_frame_paint_is_clipped_in_page_space() {
}
}
}
+
+/// "ab" in black with a green second character, as outlines and as live text.
+fn two_colour_text() -> (Document, figcraft_doc::NodeId) {
+ use figcraft_doc::{StyleRun, TextData};
+ figcraft_text::scan_system_fonts();
+ let mut d = Document::new("t");
+ let page = d.first_page().unwrap();
+ let mut text = TextData::new("ab");
+ text.style.font_size = 24.0;
+ let mut style = text.style.clone();
+ style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
+ text.runs.push(StyleRun { start: 1, end: 2, style });
+ text.normalize();
+ let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 40.0).unwrap();
+ d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
+ (d, id)
+}
+
+#[test]
+fn text_run_fill_survives_outlined_export() {
+ let (d, id) = two_colour_text();
+ let svg = export_svg(&d, &[id], &SvgOptions { outline_text: true, ..SvgOptions::default() }).unwrap();
+ assert!(svg.contains("#000000") && svg.contains("#00FF00"), "{svg}");
+ assert_eq!(svg.matches("b") && svg.contains(">a"), "{svg}");
+}