mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c93d4ad995
commit
72c9cec0e0
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
Reference in New Issue
Block a user