diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index 3fe96d694..d2400f19f 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -105,6 +105,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = text_transform(m, tr, l.rotation); + let lines_of = LineIndex::new(l); let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { let ps = para(d.para); if !ps.visible || ps.opacity <= 0.001 { @@ -144,7 +145,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, draw_glyph(ctx, run, *gid, at, ps.opacity); continue; }; - let li = line_at(l, run.para, *y); + let li = lines_of.line_at(run.para, *y); if !grads.iter().any(|(i, _)| *i == li) { let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); @@ -191,8 +192,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { } } -/// 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 { +/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's +/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text +/// crate produces) is searched by bisection; any other falls back to a scan with the same answer. +pub(crate) struct LineIndex<'a> { + l: &'a deckcraft_text::TextLayout, + /// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index. + ordered: Vec, +} + +impl<'a> LineIndex<'a> { + pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self { + let ordered = l + .para_lines + .iter() + .map(|&(first, count)| { + let end = first.saturating_add(count).min(l.lines.len()); + l.lines.get(first..end).is_some_and(|ls| { + ls.iter().all(|a| a.top <= a.bottom) + && ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline) + }) + }) + .collect(); + LineIndex { l, ordered } + } + + /// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`. + fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option { + let i = lines.partition_point(|li| li.baseline < y); + let best = match (i.checked_sub(1), lines.get(i)) { + (Some(p), Some(n)) => { + if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() { + p + } else { + i + } + } + (Some(p), None) => p, + (None, Some(_)) => i, + (None, None) => return None, + }; + let b = lines[best].baseline; + Some(lines.partition_point(|li| li.baseline < b)) + } + + /// The paragraph's lines when they are ordered, with the index of the first. + fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> { + let &(first, count) = self.l.para_lines.get(para)?; + let end = first.saturating_add(count).min(self.l.lines.len()); + (self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end])) + } + + /// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). + pub(crate) fn line_at(&self, para: usize, y: f64) -> usize { + if let Some((first, ls)) = self.ordered_lines(para) { + return Self::nearest(ls, y).map_or(0, |i| first + i); + } + line_at_scan(self.l, para, y) + } + + /// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line. + pub(crate) fn line_of(&self, para: usize, y: f64) -> Option { + let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) }; + let i = ls.partition_point(|li| li.bottom + 0.01 < y); + if ls.get(i).is_some_and(|li| y >= li.top - 0.01) { + return Some(first + i); + } + Self::nearest(ls, y).map(|i| first + i) + } +} + +/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning. +fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { if let Some(&(first, count)) = l.para_lines.get(para) { if count == 0 { return 0; @@ -318,3 +389,38 @@ fn draw_glyph_with( ctx.stroke_path(&path); } } + +#[cfg(test)] +mod line_index_tests { + use super::*; + use deckcraft_text::{LineInfo, TextLayout}; + + fn layout(ordered: bool) -> TextLayout { + let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() }; + let mut lines: Vec = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect(); + if !ordered { + lines.swap(3, 7); + } + TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() } + } + + #[test] + fn bisection_matches_the_scan() { + for ordered in [true, false] { + let l = layout(ordered); + let idx = LineIndex::new(l_ref(&l)); + assert_eq!(idx.ordered, vec![ordered, true]); + for para in 0..2 { + for k in -20..450 { + let y = f64::from(k) * 0.9; + assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}"); + assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}"); + } + } + } + } + + fn l_ref(l: &TextLayout) -> &TextLayout { + l + } +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs index 58caa2ead..72fcbbbe5 100644 --- a/crates/render/src/warp.rs +++ b/crates/render/src/warp.rs @@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout}; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; use vello_cpu::{RenderContext, peniko}; +use crate::text_draw::LineIndex; use crate::{ParaState, color, glyph, scale_of, text_transform}; /// Most points one warped glyph may have (a hostile size or zoom can't explode the work). const MAX_POINTS: usize = 40_000; -/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to. -fn line_of(l: &TextLayout, para: usize, y: f64) -> Option { +/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning. +pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option { if let Some(&(first, count)) = l.para_lines.get(para) { if count == 0 { return None; @@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = text_transform(m, tr, l.rotation); let n = l.lines.len(); + let lines_of = LineIndex::new(l); let mut ink: Vec> = vec![None; n]; let mut off: Vec> = vec![None; n]; let mut items: Vec = Vec::new(); @@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, let k = run.size / run.face.upem.max(1.0); let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; for (gid, x, y) in &run.glyphs { - let Some(li) = line_of(l, run.para, *y) else { continue }; + let Some(li) = lines_of.line_of(run.para, *y) else { continue }; if let Some(o) = off.get_mut(li) { *o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run)))); } @@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, return; } let y = (d.rect.y0 + d.rect.y1) / 2.0; - let Some(li) = line_of(l, d.para, y) else { return }; + let Some(li) = lines_of.line_of(d.para, y) else { return }; ctx.set_transform(m * Affine::translate(ps.offset)); ctx.set_paint(color(d.color, ps.opacity)); for r in d.pieces() {