From 72c9cec0e0f24c3f3fe2ce33345c8b4665854a2f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:03:04 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/typecmd.rs | 7 ++++++ crates/pdf/src/export.rs | 16 ++++++++++++- crates/render/src/lib.rs | 7 ++++++ crates/svg/src/export.rs | 22 ++++++++++++------ crates/text/src/fontdb.rs | 17 ++++++++++++-- crates/text/src/layout.rs | 40 ++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/tests.rs | 23 ++++++++++++++++++ 8 files changed, 123 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index a9921c6e..4d985a44 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { 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() { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d59dbe4c..d0ed2471 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3ca4de81..9a9899a7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1680,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> 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 } } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index a67d91c4..7fc8c327 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1638,8 +1638,13 @@ impl Writer<'_> { let a = self.attrs(&css::transparency(n)); self.line(&format!("")); 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); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 74aec355..edd3bcdd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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 0.0).unwrap_or(m.ascent as f64 * 0.5); + let decoration = |d: Option, 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 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, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 73de86f1..bba0a7c1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 0c94e5bc..1115df74 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 35339fe4..6dc4e3d7 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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) }));