mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 02:30:13 +00:00
724 lines
26 KiB
Rust
724 lines
26 KiB
Rust
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
|
|
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
|
|
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
|
|
//!
|
|
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
|
|
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
|
|
//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the
|
|
//! origin is the top-left of the text box and lines wrap at `w`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::ops::Range;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use harfrust::{Direction, Feature, Tag, UnicodeBuffer};
|
|
use unicode_bidi::{BidiInfo, Level};
|
|
|
|
use crate::fonts::{self, FaceId, Resolved};
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
|
pub enum Caps {
|
|
#[default]
|
|
Normal,
|
|
All,
|
|
Small,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
|
pub enum Align {
|
|
#[default]
|
|
Left,
|
|
Center,
|
|
Right,
|
|
Justify,
|
|
}
|
|
|
|
/// Character formatting.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TextStyle {
|
|
pub family: String,
|
|
pub style: String,
|
|
/// Font size in pixels.
|
|
pub size: f32,
|
|
/// Tracking in 1/1000 em (added after every cluster).
|
|
pub tracking: f32,
|
|
/// Metric kerning (`kern`).
|
|
pub kerning: bool,
|
|
/// Standard ligatures (`liga`, `clig`).
|
|
pub ligatures: bool,
|
|
/// Baseline shift in pixels (positive = up).
|
|
pub baseline_shift: f32,
|
|
pub faux_bold: bool,
|
|
pub faux_italic: bool,
|
|
pub caps: Caps,
|
|
pub underline: bool,
|
|
}
|
|
|
|
impl Default for TextStyle {
|
|
fn default() -> Self {
|
|
Self {
|
|
family: fonts::DEFAULT_FAMILY.into(),
|
|
style: "Regular".into(),
|
|
size: 100.0,
|
|
tracking: 0.0,
|
|
kerning: true,
|
|
ligatures: true,
|
|
baseline_shift: 0.0,
|
|
faux_bold: false,
|
|
faux_italic: false,
|
|
caps: Caps::Normal,
|
|
underline: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Paragraph formatting.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ParagraphStyle {
|
|
pub align: Align,
|
|
/// Extra line spacing in pixels added to the font's natural line height (Leading).
|
|
pub leading: f32,
|
|
/// Wrap width (area text); None = point text.
|
|
pub width: Option<f32>,
|
|
/// Base direction: None = from the first strong character.
|
|
pub rtl: Option<bool>,
|
|
}
|
|
|
|
fn hf(h: &mut impl Hasher, v: f32) {
|
|
v.to_bits().hash(h);
|
|
}
|
|
|
|
impl Hash for TextStyle {
|
|
fn hash<H: Hasher>(&self, h: &mut H) {
|
|
self.family.hash(h);
|
|
self.style.hash(h);
|
|
hf(h, self.size);
|
|
hf(h, self.tracking);
|
|
hf(h, self.baseline_shift);
|
|
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
|
|
}
|
|
}
|
|
|
|
impl Hash for ParagraphStyle {
|
|
fn hash<H: Hasher>(&self, h: &mut H) {
|
|
self.align.hash(h);
|
|
hf(h, self.leading);
|
|
self.width.map(f32::to_bits).hash(h);
|
|
self.rtl.hash(h);
|
|
}
|
|
}
|
|
|
|
/// A positioned glyph. `(x, y)` is the glyph origin on its baseline.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Glyph {
|
|
pub face: FaceId,
|
|
pub id: u32,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub size: f32,
|
|
/// Byte offset of the cluster in the source text.
|
|
pub cluster: usize,
|
|
pub synth_bold: bool,
|
|
pub synth_italic: bool,
|
|
}
|
|
|
|
/// One laid-out line.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Line {
|
|
/// Byte range in the source text (without the terminating newline).
|
|
pub range: Range<usize>,
|
|
pub baseline: f32,
|
|
/// Left edge and width of the line's content (trailing spaces excluded).
|
|
pub x: f32,
|
|
pub width: f32,
|
|
pub ascent: f32,
|
|
pub descent: f32,
|
|
pub glyphs: Range<usize>,
|
|
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
|
|
pub carets: Vec<(usize, f32)>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Layout {
|
|
pub glyphs: Vec<Glyph>,
|
|
pub lines: Vec<Line>,
|
|
/// Underline rectangles `[x0, y0, x1, y1]`.
|
|
pub underlines: Vec<[f32; 4]>,
|
|
/// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width).
|
|
pub bounds: [f32; 4],
|
|
/// The face requested was missing (substituted).
|
|
pub missing_font: bool,
|
|
pub text_len: usize,
|
|
}
|
|
|
|
impl Layout {
|
|
/// Line index for a caret at byte `pos`.
|
|
pub fn line_of(&self, pos: usize) -> usize {
|
|
let mut li = 0;
|
|
for (i, l) in self.lines.iter().enumerate() {
|
|
if l.range.start <= pos {
|
|
li = i;
|
|
}
|
|
}
|
|
li
|
|
}
|
|
/// Caret position `(x, baseline, line)` for byte offset `pos`.
|
|
pub fn caret(&self, pos: usize) -> (f32, f32, usize) {
|
|
let li = self.line_of(pos);
|
|
let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) };
|
|
let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1);
|
|
(x, l.baseline, li)
|
|
}
|
|
/// Nearest caret byte offset to point `(x, y)`.
|
|
pub fn hit(&self, x: f32, y: f32) -> usize {
|
|
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
|
|
return 0;
|
|
};
|
|
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
|
}
|
|
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
|
|
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
|
|
let (a, b) = (a.min(b), a.max(b));
|
|
let mut out = Vec::new();
|
|
for l in &self.lines {
|
|
let s = a.max(l.range.start);
|
|
let e = b.min(l.range.end);
|
|
if s > e || (s == e && !(a < l.range.start && b > l.range.end)) {
|
|
continue;
|
|
}
|
|
let xs: Vec<f32> = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect();
|
|
if xs.len() < 2 && b <= l.range.end {
|
|
continue;
|
|
}
|
|
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
|
|
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
|
|
if b > l.range.end {
|
|
x1 += l.ascent * 0.25; // selected newline
|
|
}
|
|
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn line_dist(l: &Line, y: f32) -> f32 {
|
|
let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent);
|
|
if y < t {
|
|
t - y
|
|
} else if y > b {
|
|
y - b
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
struct ShapedGlyph {
|
|
face: FaceId,
|
|
id: u32,
|
|
cluster: usize,
|
|
adv: f32,
|
|
dx: f32,
|
|
dy: f32,
|
|
size: f32,
|
|
}
|
|
|
|
struct Item {
|
|
range: Range<usize>,
|
|
rtl: bool,
|
|
glyphs: Vec<ShapedGlyph>,
|
|
}
|
|
|
|
fn upper_single(c: char) -> char {
|
|
let mut u = c.to_uppercase();
|
|
match (u.next(), u.next()) {
|
|
(Some(x), None) => x,
|
|
_ => c,
|
|
}
|
|
}
|
|
|
|
const SMALL_CAPS_SCALE: f32 = 0.78;
|
|
|
|
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
|
|
let f = fonts::face(face);
|
|
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
|
|
let shaper = data.shaper(&font).build();
|
|
let mut buf = UnicodeBuffer::new();
|
|
for &(b, _, sc) in chars {
|
|
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
|
|
}
|
|
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
|
buf.guess_segment_properties();
|
|
let mut feats = Vec::new();
|
|
if !style.kerning {
|
|
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
|
|
}
|
|
if !style.ligatures {
|
|
feats.push(Feature::new(Tag::new(b"liga"), 0, ..));
|
|
feats.push(Feature::new(Tag::new(b"clig"), 0, ..));
|
|
}
|
|
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
|
|
let k = size / f.units_per_em();
|
|
let _ = text;
|
|
out.glyph_infos()
|
|
.iter()
|
|
.zip(out.glyph_positions())
|
|
.map(|(i, p)| ShapedGlyph {
|
|
face,
|
|
id: i.glyph_id,
|
|
cluster: i.cluster as usize,
|
|
adv: p.x_advance as f32 * k,
|
|
dx: p.x_offset as f32 * k,
|
|
dy: p.y_offset as f32 * k,
|
|
size,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct ParaLine {
|
|
range: Range<usize>,
|
|
glyphs: Vec<Glyph>,
|
|
carets: Vec<(usize, f32)>,
|
|
content: f32,
|
|
left_trim: f32,
|
|
ascent: f32,
|
|
descent: f32,
|
|
}
|
|
|
|
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
|
|
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
|
|
let pm = fonts::face(primary.face).metrics(style.size);
|
|
if text.is_empty() {
|
|
return vec![ParaLine {
|
|
range: base..base,
|
|
glyphs: vec![],
|
|
carets: vec![(base, 0.0)],
|
|
content: 0.0,
|
|
left_trim: 0.0,
|
|
ascent: pm.ascent,
|
|
descent: pm.descent,
|
|
}];
|
|
}
|
|
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
|
|
let bidi = BidiInfo::new(text, default_level);
|
|
let pinfo = &bidi.paragraphs[0];
|
|
let base_rtl = pinfo.level.is_rtl();
|
|
// per char: (byte, char, shaped char), face, level, small
|
|
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
|
|
.char_indices()
|
|
.map(|(b, c)| {
|
|
let (sc, small) = match style.caps {
|
|
Caps::Normal => (c, false),
|
|
Caps::All => (upper_single(c), false),
|
|
Caps::Small if c.is_lowercase() => (upper_single(c), true),
|
|
Caps::Small => (c, false),
|
|
};
|
|
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
|
|
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
|
|
})
|
|
.collect();
|
|
// items: runs of equal (face, rtl, small)
|
|
let mut items: Vec<Item> = Vec::new();
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
|
|
let mut j = i + 1;
|
|
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
|
|
j += 1;
|
|
}
|
|
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
|
|
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
|
|
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
|
|
// tracking after each cluster
|
|
if style.tracking != 0.0 {
|
|
let t = style.tracking * style.size / 1000.0;
|
|
for k in 0..glyphs.len() {
|
|
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
|
glyphs[k].adv += t;
|
|
}
|
|
}
|
|
}
|
|
let end = if j < chars.len() { chars[j].0 } else { text.len() };
|
|
items.push(Item { range: chars[i].0..end, rtl, glyphs });
|
|
i = j;
|
|
}
|
|
// advance per char byte (cluster start)
|
|
let mut char_adv = vec![0.0f32; text.len() + 1];
|
|
for it in &items {
|
|
for g in &it.glyphs {
|
|
char_adv[g.cluster.min(text.len())] += g.adv;
|
|
}
|
|
}
|
|
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
|
|
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
|
|
let mut end = r.end;
|
|
let mut w = 0.0;
|
|
for (b, c) in text[r.clone()].char_indices().rev() {
|
|
if !c.is_whitespace() {
|
|
break;
|
|
}
|
|
end = r.start + b;
|
|
w += char_adv[r.start + b];
|
|
}
|
|
(end, w)
|
|
};
|
|
// line breaking
|
|
let mut ranges: Vec<Range<usize>> = Vec::new();
|
|
match para.width {
|
|
None => ranges.push(0..text.len()),
|
|
Some(maxw) => {
|
|
let mut start = 0usize;
|
|
let mut last_ok: Option<usize> = None;
|
|
for (p, _) in unicode_linebreak::linebreaks(text) {
|
|
loop {
|
|
let r = start..p;
|
|
let (ce, _) = trailing_ws(&r);
|
|
let w = range_w(start..ce);
|
|
if w <= maxw || last_ok.is_none() {
|
|
last_ok = Some(p);
|
|
break;
|
|
}
|
|
let b = last_ok.take().expect("checked");
|
|
ranges.push(start..b);
|
|
start = b;
|
|
}
|
|
}
|
|
ranges.push(start..text.len());
|
|
ranges.retain(|r| !r.is_empty());
|
|
if ranges.is_empty() {
|
|
ranges.push(0..text.len());
|
|
}
|
|
}
|
|
}
|
|
let nlines = ranges.len();
|
|
let mut out = Vec::new();
|
|
for (li, r) in ranges.into_iter().enumerate() {
|
|
let (ce, tw) = trailing_ws(&r);
|
|
let content = range_w(r.start..ce);
|
|
// justification
|
|
let mut space_extra = 0.0;
|
|
if para.align == Align::Justify
|
|
&& li + 1 < nlines
|
|
&& let Some(maxw) = para.width
|
|
{
|
|
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
|
|
if spaces > 0 {
|
|
space_extra = ((maxw - content) / spaces as f32).max(0.0);
|
|
}
|
|
}
|
|
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
|
|
let mut pen = 0.0f32;
|
|
let mut glyphs = Vec::new();
|
|
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
|
|
for run in runs {
|
|
let rtl = levels[run.start].is_rtl();
|
|
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
|
|
if rtl {
|
|
idx.reverse();
|
|
}
|
|
for k in idx {
|
|
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
|
|
let x0 = pen;
|
|
glyphs.push(Glyph {
|
|
face: g.face,
|
|
id: g.id,
|
|
x: pen + g.dx,
|
|
y: -g.dy - style.baseline_shift,
|
|
size: g.size,
|
|
cluster: base + g.cluster,
|
|
synth_bold: primary.synth_bold || style.faux_bold,
|
|
synth_italic: primary.synth_italic || style.faux_italic,
|
|
});
|
|
pen += g.adv;
|
|
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
|
|
pen += space_extra;
|
|
}
|
|
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
|
|
e.0 = e.0.min(x0);
|
|
e.1 = e.1.max(pen);
|
|
}
|
|
}
|
|
}
|
|
// caret stops for each char boundary in the line
|
|
let mut carets = Vec::new();
|
|
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
|
|
cluster_starts.sort_unstable();
|
|
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
|
|
for (ci, &cs) in cluster_starts.iter().enumerate() {
|
|
let (x0, x1, rtl) = extents[&cs];
|
|
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
|
|
let members: Vec<usize> = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
|
|
let n = members.len().max(1) as f32;
|
|
for (mi, b) in members.iter().enumerate() {
|
|
let f = mi as f32 / n;
|
|
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
|
|
carets.push((base + b, x));
|
|
}
|
|
}
|
|
// end of line caret
|
|
let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
|
|
Some(c) => {
|
|
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
|
|
match cs.and_then(|s| extents.get(&s)) {
|
|
Some(&(x0, x1, rtl)) => {
|
|
if rtl {
|
|
x0
|
|
} else {
|
|
x1
|
|
}
|
|
}
|
|
None => pen,
|
|
}
|
|
}
|
|
None => 0.0,
|
|
};
|
|
carets.push((base + r.end, end_x));
|
|
carets.sort_by_key(|c| c.0);
|
|
carets.dedup_by_key(|c| c.0);
|
|
let mut asc = pm.ascent;
|
|
let mut desc = pm.descent;
|
|
for g in &glyphs {
|
|
if g.face != primary.face {
|
|
let m = fonts::face(g.face).metrics(g.size);
|
|
asc = asc.max(m.ascent);
|
|
desc = desc.max(m.descent);
|
|
}
|
|
}
|
|
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
|
|
out.push(ParaLine {
|
|
range: base + r.start..base + r.end,
|
|
glyphs,
|
|
carets,
|
|
content: content_w,
|
|
left_trim: if base_rtl { tw } else { 0.0 },
|
|
ascent: asc,
|
|
descent: desc,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Lay out `text` (paragraphs separated by `\n`).
|
|
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
|
|
let primary = fonts::resolve(&style.family, &style.style);
|
|
let pm = fonts::face(primary.face).metrics(style.size);
|
|
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
|
|
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
|
|
let mut base = 0usize;
|
|
let mut plines = Vec::new();
|
|
for p in text.split('\n') {
|
|
let p_clean = p.strip_suffix('\r').unwrap_or(p);
|
|
plines.extend(paragraph(p_clean, base, style, para, primary));
|
|
base += p.len() + 1;
|
|
}
|
|
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
|
|
for (i, pl) in plines.into_iter().enumerate() {
|
|
let baseline = first_baseline + line_h * i as f32;
|
|
let shift = match (para.width, para.align) {
|
|
(None, Align::Left | Align::Justify) => 0.0,
|
|
(None, Align::Center) => -pl.content / 2.0,
|
|
(None, Align::Right) => -pl.content,
|
|
(Some(_), Align::Left | Align::Justify) => 0.0,
|
|
(Some(w), Align::Center) => (w - pl.content) / 2.0,
|
|
(Some(w), Align::Right) => w - pl.content,
|
|
} - pl.left_trim;
|
|
let g0 = lay.glyphs.len();
|
|
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
|
|
g.x += shift;
|
|
g.y += baseline;
|
|
g
|
|
}));
|
|
let x = shift + pl.left_trim;
|
|
if style.underline && pl.content > 0.0 {
|
|
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
|
|
}
|
|
lay.lines.push(Line {
|
|
range: pl.range,
|
|
baseline,
|
|
x,
|
|
width: pl.content,
|
|
ascent: pl.ascent,
|
|
descent: pl.descent,
|
|
glyphs: g0..lay.glyphs.len(),
|
|
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
|
|
});
|
|
}
|
|
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
|
|
for l in &lay.lines {
|
|
x0 = x0.min(l.x);
|
|
x1 = x1.max(l.x + l.width);
|
|
}
|
|
if let Some(w) = para.width {
|
|
x0 = 0.0;
|
|
x1 = x1.max(w);
|
|
}
|
|
let first = &lay.lines[0];
|
|
let last = lay.lines.last().expect("at least one line");
|
|
lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent];
|
|
lay
|
|
}
|
|
|
|
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
|
|
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
|
|
C.get_or_init(Default::default)
|
|
}
|
|
|
|
/// Lay out `text` (cached by text and styles).
|
|
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
text.hash(&mut h);
|
|
style.hash(&mut h);
|
|
para.hash(&mut h);
|
|
let key = h.finish();
|
|
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) {
|
|
e.0 = now;
|
|
return e.1.clone();
|
|
}
|
|
let l = Arc::new(layout_uncached(text, style, para));
|
|
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
|
if c.len() >= 512 {
|
|
// evict the oldest quarter
|
|
let mut ages: Vec<u64> = c.values().map(|v| v.0).collect();
|
|
ages.sort_unstable();
|
|
let cut = ages[ages.len() / 4];
|
|
c.retain(|_, v| v.0 > cut);
|
|
}
|
|
c.insert(key, (now, l.clone()));
|
|
l
|
|
}
|
|
|
|
/// Width of a single line of `text` (no wrapping).
|
|
pub fn measure(text: &str, style: &TextStyle) -> f32 {
|
|
let l = layout(text, style, &ParagraphStyle::default());
|
|
l.lines.iter().map(|l| l.width).fold(0.0, f32::max)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn st(size: f32) -> TextStyle {
|
|
TextStyle { size, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn kerning_and_ligatures_change_advances() {
|
|
let kern = measure("AVAVAV", &st(100.0));
|
|
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..st(100.0) });
|
|
assert!(kern < nokern - 5.0, "kerning tightens AV: {kern} vs {nokern}");
|
|
// tracking adds 1/1000 em per cluster
|
|
let tracked = measure("AVAVAV", &TextStyle { tracking: 100.0, ..st(100.0) });
|
|
assert!((tracked - kern - 6.0 * 10.0).abs() < 0.5, "{tracked} {kern}");
|
|
}
|
|
|
|
#[test]
|
|
fn point_text_alignment() {
|
|
let l = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Center, ..Default::default() });
|
|
let line = &l.lines[0];
|
|
assert!((line.x + line.width / 2.0).abs() < 0.01);
|
|
let r = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Right, ..Default::default() });
|
|
assert!((r.lines[0].x + r.lines[0].width).abs() < 0.01);
|
|
assert_eq!(l.glyphs.len(), 5);
|
|
assert!(l.bounds[1] < -30.0 && l.bounds[3] > 5.0, "{:?}", l.bounds);
|
|
}
|
|
|
|
#[test]
|
|
fn wraps_area_text_and_justifies() {
|
|
let p = ParagraphStyle { width: Some(300.0), align: Align::Justify, ..Default::default() };
|
|
let l = layout("the quick brown fox jumps over the lazy dog again and again", &st(40.0), &p);
|
|
assert!(l.lines.len() >= 3, "{}", l.lines.len());
|
|
for line in &l.lines[..l.lines.len() - 1] {
|
|
assert!((line.width - 300.0).abs() < 0.5, "justified to the box: {}", line.width);
|
|
}
|
|
// the lines tile the text
|
|
assert_eq!(l.lines[0].range.start, 0);
|
|
for w in l.lines.windows(2) {
|
|
assert_eq!(w[0].range.end, w[1].range.start);
|
|
assert!(w[1].baseline > w[0].baseline);
|
|
}
|
|
let left = layout("the quick brown fox jumps over the lazy dog", &st(40.0), &ParagraphStyle { width: Some(300.0), ..Default::default() });
|
|
assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01));
|
|
}
|
|
|
|
#[test]
|
|
fn newlines_make_paragraphs_and_leading_adds() {
|
|
let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default());
|
|
assert_eq!(l.lines.len(), 4);
|
|
assert_eq!(l.lines[1].range, 4..7);
|
|
assert_eq!(l.lines[2].range, 8..8);
|
|
let gap = l.lines[1].baseline - l.lines[0].baseline;
|
|
let l2 = layout("one\ntwo", &st(30.0), &ParagraphStyle { leading: 10.0, ..Default::default() });
|
|
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
|
|
}
|
|
|
|
#[test]
|
|
fn carets_and_hit_testing() {
|
|
let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
|
|
let (x0, _, li0) = l.caret(0);
|
|
let (x1, _, _) = l.caret(1);
|
|
let (x3, _, _) = l.caret(3);
|
|
assert!(x0.abs() < 1e-3 && x1 > x0 && x3 > x1);
|
|
assert_eq!(li0, 0);
|
|
let (_, b4, li4) = l.caret(4);
|
|
assert_eq!(li4, 1);
|
|
assert!(b4 > 0.0);
|
|
assert_eq!(l.hit(x1 + 1.0, 0.0), 1);
|
|
assert_eq!(l.hit(1000.0, b4), 6);
|
|
let rects = l.selection_rects(1, 5);
|
|
assert_eq!(rects.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn bidi_reorders_rtl_runs() {
|
|
// Hebrew letters are laid out right-to-left: the first logical letter is rightmost.
|
|
let l = layout("ab \u{5d0}\u{5d1}\u{5d2} cd", &st(40.0), &ParagraphStyle::default());
|
|
let x_of = |byte: usize| l.glyphs.iter().find(|g| g.cluster == byte).map(|g| g.x).unwrap();
|
|
let alef = "ab ".len();
|
|
let gimel = alef + 4;
|
|
assert!(x_of(alef) > x_of(gimel), "alef right of gimel");
|
|
assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of("ab \u{5d0}\u{5d1}\u{5d2} ".len()));
|
|
}
|
|
|
|
#[test]
|
|
fn caps_and_small_caps() {
|
|
let s = st(50.0);
|
|
let up = measure("HELLO", &s);
|
|
let all = measure("hello", &TextStyle { caps: Caps::All, ..s.clone() });
|
|
assert!((up - all).abs() < 0.01);
|
|
let small = measure("hello", &TextStyle { caps: Caps::Small, ..s.clone() });
|
|
assert!(small < up * 0.9 && small > up * 0.6, "{small} {up}");
|
|
let l = layout("Hi", &TextStyle { baseline_shift: 10.0, underline: true, ..s }, &ParagraphStyle::default());
|
|
assert!((l.glyphs[0].y + 10.0).abs() < 1e-3);
|
|
assert_eq!(l.underlines.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn ligature_caret_interpolates() {
|
|
// Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most
|
|
// fonts ligate. Whatever shaping does, every char boundary has a caret.
|
|
let l = layout("office", &st(40.0), &ParagraphStyle::default());
|
|
let stops: Vec<usize> = l.lines[0].carets.iter().map(|c| c.0).collect();
|
|
assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]);
|
|
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
|
|
assert!(xs.windows(2).all(|w| w[1] >= w[0]), "{xs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_font_for_missing_glyphs() {
|
|
// Inter lacks Devanagari/Hebrew; Noto Serif lacks Hebrew too → glyphs still produced (notdef)
|
|
let l = layout("A\u{3b1}", &TextStyle { family: "Noto Serif".into(), ..st(40.0) }, &ParagraphStyle::default());
|
|
assert_eq!(l.glyphs.len(), 2);
|
|
assert!(l.glyphs.iter().all(|g| g.id != 0));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_text_has_a_caret() {
|
|
let l = layout("", &st(40.0), &ParagraphStyle::default());
|
|
assert_eq!(l.lines.len(), 1);
|
|
assert_eq!(l.caret(0).0, 0.0);
|
|
assert!(l.bounds[3] > l.bounds[1]);
|
|
}
|
|
}
|