SVG: type written as outlines stacks colour glyphs under their run's paint

As on the canvas: each run's bitmap, COLR and SVG glyphs come before the run's path, so a plain character set over a colour one stays on top.
This commit is contained in:
Jürg Lehni
2026-10-24 02:06:13 +07:00
committed by bflatastic
parent c8fe9193d9
commit a26ac22794
3 changed files with 43 additions and 27 deletions
+26 -23
View File
@@ -1920,11 +1920,11 @@ impl Writer<'_> {
} }
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span /// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas). /// the whole text), over its colour glyphs, as on the canvas.
fn text_outlines(&mut self, n: &Node, t: &TextObject) { fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let db = vectorcraft_text::FontDb::global(); let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t); let lay = vectorcraft_text::layout(db, t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
// Underline and strikethrough bars join their run's outlines; ruby glyphs are painted // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted
// with the run of the text they annotate. // with the run of the text they annotate.
let bars = vectorcraft_text::decorations(&lay, db, t); let bars = vectorcraft_text::decorations(&lay, db, t);
@@ -1935,9 +1935,8 @@ impl Writer<'_> {
.chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) .chain(lay.ruby.iter().map(|g| (g.run, &g.outline)))
.chain(bars.iter().map(|(r, b)| (*r, b))) .chain(bars.iter().map(|(r, b)| (*r, b)))
{ {
match runs.iter_mut().find(|(r, _)| *r == run) { if let Some(bp) = runs.get_mut(run) {
Some((_, bp)) => bp.extend(outline.iter()), bp.extend(outline.iter());
None => runs.push((run, outline.clone())),
} }
} }
let id = self.id_attr(n); let id = self.id_attr(n);
@@ -1947,29 +1946,33 @@ impl Writer<'_> {
// The glyphs stay in text space; the text's transform joins the document's meanwhile. // The glyphs stay in text space; the text's transform joins the document's meanwhile.
let saved = self.xf; let saved = self.xf;
self.xf = saved * t.xf; self.xf = saved * t.xf;
for (r, bp) in runs { for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() {
let Some(run) = t.runs.get(r) else { continue }; // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as
let pd = PathData::from_bezpath(&bp); // their images, COLR and SVG glyphs as their art.
for g in lay.glyphs.iter().filter(|g| g.run == i) {
if let Some((image, to_text)) = g.placed_image() {
let m = self.matrix(self.xf * to_text);
let href = self.blob_href(&image.blob);
self.line(&format!(
"<image width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
image.width,
image.height,
xml_escape(&href)
));
}
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
self.node(&art);
}
}
if bp.elements().is_empty() {
continue;
}
let pd = PathData::from_bezpath(bp);
// An unnamed id: the pieces carry no id attribute (the group has it). // An unnamed id: the pieces carry no id attribute (the group has it).
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
let d = self.path_d(&pd, self.xf); let d = self.path_d(&pd, self.xf);
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
} }
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
for (image, to_text) in lay.glyphs.iter().filter_map(|g| g.placed_image()) {
let m = self.matrix(self.xf * to_text);
let href = self.blob_href(&image.blob);
self.line(&format!(
"<image width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
image.width,
image.height,
xml_escape(&href)
));
}
// COLR glyphs: their layers, as art.
for art in lay.glyphs.iter().filter_map(|g| g.placed_colour(Affine::IDENTITY)) {
self.node(&art);
}
self.xf = saved; self.xf = saved;
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
+15 -3
View File
@@ -43,14 +43,14 @@ fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() {
assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text"); assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text");
} }
/// `ch` in the COLR test font (v1 or v0), 24 pt, filled blue. /// `text` in the COLR test font (v1 or v0), 24 pt, filled blue.
fn colr_doc(v1: bool, ch: char) -> Document { fn colr_doc(v1: bool, text: impl std::fmt::Display) -> Document {
use vectorcraft_text::test_fonts::{colr_family, colr_font}; use vectorcraft_text::test_fonts::{colr_family, colr_font};
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap()); vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
let mut d = Document::new(200.0, 100.0); let mut d = Document::new(200.0, 100.0);
let fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(0, 0, 255)); let fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(0, 0, 255));
let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() }; let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() };
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st)))); let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &text.to_string(), st))));
let l = d.layers[0].id; let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap(); d.insert(Some(l), 0, n).unwrap();
d d
@@ -82,3 +82,15 @@ fn embedded_fonts_leave_colr_glyphs_to_the_viewer() {
assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings); assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings);
assert!(out.svg.contains(COLOUR_CHAR), "the character stays text"); assert!(out.svg.contains(COLOUR_CHAR), "the character stays text");
} }
/// Outlined text stacks as the canvas paints type: a run's colour glyphs under its paint, so a
/// plain character set over a colour one stays on top.
#[test]
fn outlined_colour_glyphs_stay_under_their_runs_paint() {
let outlined = ExportOptions { outline_text: true, ..Default::default() };
let svg = export_full(&doc(), &outlined, None).svg;
assert!(svg.find("<image ").unwrap() < svg.find("<path").unwrap(), "the emoji, then the run: {svg}");
let svg = export_full(&colr_doc(false, format!("{COLOUR_CHAR}F")), &outlined, None).svg;
let first = &svg[svg.find("<path").unwrap()..];
assert!(first[..first.find('>').unwrap()].contains("f00"), "the red layer first: {svg}");
}
File diff suppressed because one or more lines are too long