Merge pull request #850 from storytold/fix/847-underline-strikethrough
Fix #847: draw Underline and Strikethrough
This commit is contained in:
commit
338ed529e4
|
|
@ -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) }));
|
||||
|
|
|
|||
Loading…
Reference in New Issue