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
/// 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) {
let db = vectorcraft_text::FontDb::global();
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
// with the run of the text they annotate.
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(bars.iter().map(|(r, b)| (*r, b)))
{
match runs.iter_mut().find(|(r, _)| *r == run) {
Some((_, bp)) => bp.extend(outline.iter()),
None => runs.push((run, outline.clone())),
if let Some(bp) = runs.get_mut(run) {
bp.extend(outline.iter());
}
}
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.
let saved = self.xf;
self.xf = saved * t.xf;
for (r, bp) in runs {
let Some(run) = t.runs.get(r) else { continue };
let pd = PathData::from_bezpath(&bp);
for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() {
// Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as
// 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).
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
let d = self.path_d(&pd, self.xf);
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.depth -= 1;
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");
}
/// `ch` in the COLR test font (v1 or v0), 24 pt, filled blue.
fn colr_doc(v1: bool, ch: char) -> Document {
/// `text` in the COLR test font (v1 or v0), 24 pt, filled blue.
fn colr_doc(v1: bool, text: impl std::fmt::Display) -> Document {
use vectorcraft_text::test_fonts::{colr_family, colr_font};
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
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 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;
d.insert(Some(l), 0, n).unwrap();
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.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