Fix #847: draw Underline and Strikethrough

The styles were stored on the text and written to SVG's text-decoration,
but the type was drawn without the lines. vectorcraft_text::decorations
now gives each underlined or struck-through stretch of a run on a line
its bar, placed and sized from the font's own underline and strikeout
metrics (with fallbacks for fonts that have none). The canvas, Create
Outlines, PDF export (outlined and real text) and SVG's outlined text
draw them in the run's paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-10 06:03:04 +07:00
co-authored by Claude Opus 5.5
parent c93d4ad995
commit 72c9cec0e0
8 changed files with 123 additions and 11 deletions
+7
View File
@@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
node.appearance = st.appearance();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), &t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance();
children.push(Arc::new(node));
}
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
d.remove(tid)?;
if children.is_empty() {
+15 -1
View File
@@ -1304,6 +1304,9 @@ impl Exporter<'_> {
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(&all).map(|p| (all, p))
});
@@ -1314,16 +1317,27 @@ impl Exporter<'_> {
s.push_transform(&xf(t.xf));
// Real text: the characters' fills as text in embedded fonts.
let plain = (!self.outline_text).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
// Underline and strikethrough bars are paths, with real text too (#847).
let mut run_bars = BezPath::new();
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
run_bars.extend(bar.iter());
}
bp.extend(run_bars.iter());
let fill = &run.style.fill;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
Some(text) => {
let mut left = ex.glyph_text(s, &layout, i, text, fill);
left.extend(run_bars.iter());
Cow::Owned(left)
}
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
+7
View File
@@ -1680,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
}
+15 -7
View File
@@ -1638,8 +1638,13 @@ impl Writer<'_> {
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut all = lay.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
all.extend(bar.iter());
}
let pd = PathData::from_bezpath(&all).transformed(t.xf);
let d = self.path_d(&pd, self.xf);
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
let (below, above) = n.appearance.split_contents();
@@ -1671,12 +1676,15 @@ impl Writer<'_> {
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas).
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
for g in &lay.glyphs {
match runs.last_mut() {
Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()),
_ => runs.push((g.run, g.outline.clone())),
// Underline and strikethrough bars join their run's outlines.
let bars = vectorcraft_text::decorations(&lay, db, t);
for (run, outline) in lay.glyphs.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())),
}
}
let id = self.id_attr(n);
+15 -2
View File
@@ -72,6 +72,10 @@ pub struct FontFace {
/// values).
pub(crate) cap_height: f64,
pub(crate) x_height: f64,
/// Underline and strikeout: (offset from the baseline up to the line's top, thickness), in
/// font units (the `post` and OS/2 tables', else a common placement).
pub(crate) underline: (f64, f64),
pub(crate) strikeout: (f64, f64),
pub(crate) shaper: harfrust::ShaperData,
/// The axis settings of the variable font's named instance this face is (user units: `wght`
/// 700); empty for a static face or a variable font's default instance.
@@ -1048,6 +1052,13 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
let shaper = harfrust::ShaperData::new(&hb);
let instance =
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
let upem = m.units_per_em.max(1) as f64;
let x_height = m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5);
let decoration = |d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| {
d.map(|d| (f64::from(d.offset), f64::from(d.thickness))).filter(|(o, t)| o.is_finite() && *t > 0.0 && t.is_finite()).unwrap_or(fallback)
};
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
Some(FontFace {
keys,
traits,
@@ -1060,11 +1071,13 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
variations,
location,
instance,
upem: m.units_per_em.max(1) as f64,
upem,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
x_height,
underline,
strikeout,
shaper,
bytes,
index,
+40
View File
@@ -146,6 +146,46 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
layout_with(db, t, &opts)
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet.
pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> {
let mut out = vec![];
if layout.on_path || layout.vertical {
return out;
}
let glyphs = &layout.glyphs;
let mut i = 0;
while let Some(g) = glyphs.get(i) {
// The stretch: the glyphs of this run on this line, in a row.
let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n);
let stretch = glyphs.get(i..end).unwrap_or_default();
i = end.max(i + 1);
let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if !(style.underline || style.strikethrough) {
continue;
}
let Some(face) = db.face_by_id(g.font_id) else { continue };
let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
let (s, e) = (h.origin.x, h.origin.x + h.advance);
(a.min(s).min(e), b.max(s).max(e))
});
for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] {
if !on {
continue;
}
// Font units run y down in the glyph's transform: the bar's top is at -offset.
let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y);
let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom));
if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() {
out.push((g.run, bar.to_path(0.1)));
}
}
}
out
}
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
const SHRINK_PASSES: usize = 11;
+1 -1
View File
@@ -32,7 +32,7 @@ pub use fontdb::{
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use layout::{decorations, layout, layout_with};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
+23
View File
@@ -138,6 +138,29 @@ fn tracking_and_scale() {
assert!(b.y1 < -4.0, "shifted up: {b:?}");
}
#[test]
fn underline_and_strikethrough_bars() {
let mut t = point("ab", style(20.0));
t.runs = vec![
TextRun { text: "ab".into(), style: style(20.0), inline: None },
TextRun { text: "cd".into(), style: CharStyle { underline: true, strikethrough: true, ..style(20.0) }, inline: None },
];
let l = layout(db(), &t);
let bars = decorations(&l, db(), &t);
assert_eq!(bars.len(), 2, "one underline and one strikethrough: {bars:?}");
assert!(bars.iter().all(|(run, _)| *run == 1), "only the styled run");
let (under, strike) = (bars[0].1.bounding_box(), bars[1].1.bounding_box());
// The bars span the run's glyphs: from "c" to the end of "d".
let (c, d) = (&l.glyphs[2], &l.glyphs[3]);
assert!((under.x0 - c.origin.x).abs() < 1e-6 && (under.x1 - d.origin.x - d.advance).abs() < 1e-6, "{under:?}");
// y runs down: the underline sits below the baseline, the strikethrough above it, both thin.
assert!(under.y0 > 0.0 && under.y1 < 5.0, "{under:?}");
assert!(strike.y1 < 0.0 && strike.y0 > -10.0, "{strike:?}");
assert!(under.height() < 3.0 && strike.height() < 3.0);
// No bars for plain text.
assert!(decorations(&layout(db(), &point("ab", style(20.0))), db(), &point("ab", style(20.0))).is_empty());
}
#[test]
fn all_caps() {
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));