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