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