diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 31cb9a18..861d2bd0 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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, 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() diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1621f053..dc3c18a7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 diff --git a/crates/render/src/morph_text.rs b/crates/render/src/morph_text.rs index 40798dd9..74cd4b7f 100644 --- a/crates/render/src/morph_text.rs +++ b/crates/render/src/morph_text.rs @@ -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); } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 800f71ae..951c2917 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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 = (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})"); +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs index de0ff126..e14340e8 100644 --- a/crates/render/src/warp.rs +++ b/crates/render/src/warp.rs @@ -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); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index f587b73e..aece6a13 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 { + 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, 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 { + 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, + underline: Option<(Rgba, Underline)>, strike: Option, highlight: Option, outline: Option<(Rgba, f64)>, @@ -293,7 +437,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> 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) -> Vec { let mut out: Vec = 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; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d5c25fba..0168d2ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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::>()); 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) { + 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); +}