Text: underline styles (double, heavy, dotted, dashed, wavy) at the font's underline position

Every underline was a solid line 0.06 em thick at 0.12 em below the baseline, whatever a:rPr/@u
asked for. Underlines now sit at the font's own underline position and thickness (post table),
where PowerPoint draws them (Arial 96 pt: 10 px below the baseline, 7 px thick), and each style
is drawn as measured on PowerPoint's export: dbl two lines 0.55 thick spanning one thickness above
to the single line's bottom; heavy from one thickness above to 0.55 below; dotted dots and gaps one
thickness long; dashes three long with gaps of two; wavy a thin zigzag every 3.9 thicknesses. The
"words" style underlines spaces too, as PowerPoint draws it. Dashes and waves are new Deco line
kinds the renderer fills or strokes (warped text draws their boxes).

Underline slides: w14-xml s10 11.49 -> 10.43, s11 6.64 -> 4.06; w06-charfmt s3 9.66 -> 9.51,
s4 7.63 -> 7.44.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-13 06:48:43 -04:00
co-authored by Claude Opus 5.5
parent ed562d7305
commit 08bc46c0ea
7 changed files with 308 additions and 20 deletions
+16
View File
@@ -134,6 +134,9 @@ pub struct FontFace {
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
/// the split PowerPoint uses (computed by `line_split`).
pub line_split: f64,
/// Underline position (top edge, below the baseline) and thickness in font units (`post`
/// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there.
pub underline: (f64, f64),
pub shaper: harfrust::ShaperData,
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
/// the normalised location and the shaper's view of it.
@@ -586,6 +589,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
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),
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64),
shaper,
coords,
location,
@@ -597,6 +601,18 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
})
}
/// The underline's top edge below the baseline and its thickness, font units, from the `post`
/// table (its position is the top edge: Arial's -217/2048 is where PowerPoint's underline starts,
/// 10 px below the baseline at 96 pt); fonts without a usable one get 0.1 em and 0.05 em.
fn underline_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
match d {
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && (d.offset as f64).abs() < upem * 0.5 => {
(-(d.offset as f64), d.thickness as f64)
}
_ => (upem * 0.1, upem * 0.05),
}
}
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()
+22 -1
View File
@@ -1399,7 +1399,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
ctx.fill_rect(&d.rect);
fill_deco(ctx, d);
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
@@ -1447,6 +1447,27 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// Fill an underline, strikethrough or highlight with the current paint and transform: its box,
/// its dashes, or its zigzag stroked.
pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
if let Some((pts, w)) = d.wave() {
let mut path = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
path.move_to(*p);
} else {
path.line_to(*p);
}
}
ctx.set_stroke(kurbo::Stroke::new(w.max(0.01)));
ctx.stroke_path(&path);
return;
}
for r in d.pieces() {
ctx.fill_rect(&r);
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
l.lines
+1 -1
View File
@@ -219,7 +219,7 @@ pub(crate) fn draw(
for d in &l.decos {
ctx.set_transform(place);
ctx.set_paint(color(d.color, alpha));
ctx.fill_rect(&d.rect);
crate::fill_deco(ctx, d);
}
}
}
+31
View File
@@ -1644,3 +1644,34 @@ fn a_fill_overlay_blends_over_the_fill() {
let (over, _) = draw([200, 100, 50], "over");
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
}
#[test]
fn dotted_and_wavy_underlines_draw_their_pattern() {
// Underlined spaces only, so the underline is the only ink: a red line under " ".
let row_runs = |u: &str| {
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text(" ");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(96.0);
props.underline = Some(u.into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let red = |x: u32, y: u32| {
let px = img.pixel(x, y);
px[0] > 150 && px[1] < 120
};
// The ink's rows, and the number of separate red stretches along the most inked row.
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
(rows.len(), stretches)
};
let (sng_rows, sng_runs) = row_runs("sng");
assert_eq!(sng_runs, 1, "a single line is one stretch");
let (_, dotted_runs) = row_runs("dotted");
assert!(dotted_runs > 10, "dots: {dotted_runs}");
let (wavy_rows, _) = row_runs("wavy");
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
}
+4 -2
View File
@@ -140,10 +140,12 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return };
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
ctx.fill_path(&path);
for r in d.pieces() {
let path = warp_path(&r.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
ctx.fill_path(&path);
}
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
+190 -16
View File
@@ -93,6 +93,150 @@ pub struct Deco {
pub behind: bool,
/// Paragraph the decoration belongs to (by-paragraph animation).
pub para: usize,
/// How the line fills `rect`.
pub line: DecoLine,
}
/// How an underline fills its box.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum DecoLine {
/// The whole box.
#[default]
Solid,
/// Dashes or dots across the box's height, from its left end.
Dashed(Dash),
/// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`.
Wave { stroke: f64, period: f64 },
}
/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured
/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three
/// thicknesses long with gaps of two.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dash {
Dot,
Dash,
Long,
DotDash,
DotDotDash,
}
impl Dash {
/// On, off, on, off… lengths in thicknesses.
fn pattern(self) -> &'static [f64] {
match self {
Dash::Dot => &[1.0, 1.0],
Dash::Dash => &[3.0, 2.0],
Dash::Long => &[6.0, 2.0],
Dash::DotDash => &[3.0, 2.0, 1.0, 2.0],
Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0],
}
}
}
impl Deco {
/// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy
/// line gives a thin box along its middle, for drawing that can't stroke a path.
pub fn pieces(&self) -> Vec<Rect> {
let r = self.rect;
match self.line {
DecoLine::Solid => vec![r],
DecoLine::Wave { stroke, .. } => {
let c = (r.y0 + r.y1) / 2.0;
vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)]
}
DecoLine::Dashed(d) => {
let unit = r.height().max(0.1);
let pat = d.pattern();
let mut out = Vec::new();
let mut x = r.x0;
let mut k = 0usize;
while x < r.x1 && out.len() < 10_000 {
let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit;
if k.is_multiple_of(2) {
out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1));
}
x += len;
k += 1;
}
out
}
}
}
/// The zigzag of a wavy underline, left to right from the box's top, and its stroke width;
/// None for other lines.
pub fn wave(&self) -> Option<(Vec<Point>, f64)> {
let DecoLine::Wave { stroke, period } = self.line else { return None };
let r = self.rect;
let half = (period / 2.0).max(0.1);
let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000);
let pts = (0..=n)
.map(|i| {
let x = (r.x0 + i as f64 * half).min(r.x1);
Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 })
})
.collect();
Some((pts, stroke))
}
}
/// An underline style (`a:rPr/@u`).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Underline {
Single,
Double,
Heavy,
/// Dotted or dashed, heavy or not.
Dashed(Dash, bool),
/// Wavy, heavy or not.
Wavy(bool),
}
impl Underline {
fn from_xml(u: &str) -> Option<Self> {
Some(match u {
"none" => return None,
// PowerPoint underlines the spaces of a "words" underline too.
"words" => Underline::Single,
// At text sizes PowerPoint's double wave reads as two straight lines.
"dbl" | "wavyDbl" => Underline::Double,
"heavy" => Underline::Heavy,
"dotted" => Underline::Dashed(Dash::Dot, false),
"dottedHeavy" => Underline::Dashed(Dash::Dot, true),
"dash" => Underline::Dashed(Dash::Dash, false),
"dashHeavy" => Underline::Dashed(Dash::Dash, true),
"dashLong" => Underline::Dashed(Dash::Long, false),
"dashLongHeavy" => Underline::Dashed(Dash::Long, true),
"dotDash" => Underline::Dashed(Dash::DotDash, false),
"dotDashHeavy" => Underline::Dashed(Dash::DotDash, true),
"dotDotDash" => Underline::Dashed(Dash::DotDotDash, false),
"dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true),
"wavy" => Underline::Wavy(false),
"wavyHeavy" => Underline::Wavy(true),
_ => Underline::Single,
})
}
/// The boxes of this underline below the baseline (top, bottom, fill), from the font's
/// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export
/// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two
/// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every
/// 3.9t.
fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> {
let heavy = (p - t, p + 0.55 * t);
match self {
Underline::Single => vec![(p, p + t, DecoLine::Solid)],
Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)],
Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)],
Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))],
Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))],
Underline::Wavy(h) => {
let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 };
vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })]
}
}
}
}
#[derive(Clone, Debug, Default)]
@@ -187,7 +331,7 @@ struct Style {
baseline: f64,
spacing: f64,
caps: Caps,
underline: Option<Rgba>,
underline: Option<(Rgba, Underline)>,
strike: Option<Strike>,
highlight: Option<Rgba>,
outline: Option<(Rgba, f64)>,
@@ -293,7 +437,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
};
let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" });
let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" });
let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color));
let underline =
r.underline.as_deref().and_then(Underline::from_xml).map(|u| (r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color), u));
let outline = r.outline.as_ref().and_then(|l| match &l.fill {
Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))),
_ => None,
@@ -1580,20 +1725,47 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
continue;
}
if let Some(hl) = st.highlight {
out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para });
out.decos.push(Deco {
rect: Rect::new(cx, line.top, cx1, line.bottom),
color: hl,
behind: true,
para: line.para,
line: DecoLine::Solid,
});
}
if !cell.ch.is_whitespace() || st.underline.is_some() {
if let Some(uc) = st.underline {
let t = (st.size * 0.06).max(0.5);
let uy = line.baseline + st.size * 0.12;
out.decos.push(Deco { rect: Rect::new(cx, uy, cx1, uy + t), color: uc, behind: false, para: line.para });
if let Some((uc, u)) = st.underline {
// At the underline position and thickness of the run's own font.
let k = st.size / st.face.upem.max(1.0);
let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5));
for (y0, y1, kind) in u.boxes(p, t) {
out.decos.push(Deco {
rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1),
color: uc,
behind: false,
para: line.para,
line: kind,
});
}
}
if let Some(sk) = st.strike {
let t = (st.size * 0.05).max(0.5);
let sy = line.baseline - st.size * 0.3;
out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para });
out.decos.push(Deco {
rect: Rect::new(cx, sy, cx1, sy + t),
color: st.color,
behind: false,
para: line.para,
line: DecoLine::Solid,
});
if sk == Strike::Double {
out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), color: st.color, behind: false, para: line.para });
out.decos.push(Deco {
rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t),
color: st.color,
behind: false,
para: line.para,
line: DecoLine::Solid,
});
}
}
}
@@ -1622,13 +1794,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
fn merge_decos(v: Vec<Deco>) -> Vec<Deco> {
let mut out: Vec<Deco> = Vec::with_capacity(v.len());
for d in v {
if let Some(last) = out.last_mut()
&& last.color == d.color
&& last.behind == d.behind
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
{
// Lines drawn together (a double underline, a strikethrough) interleave: look a few back.
if let Some(last) = out.iter_mut().rev().take(4).find(|last| {
last.color == d.color
&& last.behind == d.behind
&& last.line == d.line
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
}) {
last.rect.x1 = d.rect.x1;
continue;
}
+44
View File
@@ -934,3 +934,47 @@ fn a_tab_past_the_end_moves_to_the_next_line() {
assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::<Vec<_>>());
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
}
/// Underline decorations of "ab cd" underlined `u` at 40 pt.
fn underline_decos(u: &str) -> (TextLayout, Vec<Deco>) {
let (p, mut sh) = setup("ab cd", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(40.0);
para.runs[0].props.underline = Some(u.into());
let l = lay(&p, &sh, 2000.0, 300.0);
let d = l.decos.iter().filter(|d| !d.behind).cloned().collect();
(l, d)
}
#[test]
fn underlines_sit_at_the_font_underline_in_their_style() {
let (l, sng) = underline_decos("sng");
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let k = 40.0 / r.face.upem;
let base = l.lines[0].baseline;
assert_eq!(sng.len(), 1);
assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position");
assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01);
let t = sng[0].rect.height();
// Double: two thin lines spanning from one thickness above the single line to its bottom.
let (_, dbl) = underline_decos("dbl");
assert_eq!(dbl.len(), 2);
assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01);
// Heavy is thicker; dotted is dots as long as the line is thick.
let (_, heavy) = underline_decos("heavy");
assert!(heavy[0].rect.height() > t * 1.4);
let (_, dotted) = underline_decos("dotted");
assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot));
let pieces = dotted[0].pieces();
assert!(pieces.len() > 3);
assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01);
// Wavy: a zigzag between the box's top and bottom.
let (_, wavy) = underline_decos("wavy");
let (pts, w) = wavy[0].wave().expect("a wave");
assert!(pts.len() > 4 && w > 0.0 && w < t);
assert!(pts[0].y < pts[1].y);
// "words" underlines the spaces too, as PowerPoint draws it: one line.
let (_, words) = underline_decos("words");
assert_eq!(words.len(), 1);
}