effectcraft/crates/text/src/layout.rs

1403 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line),
//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and
//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps
//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and
//! paragraph settings. 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 effectcraft_keyframe::OpenType;
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,
}
/// Superscript / subscript position.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Script {
#[default]
Normal,
Super,
Sub,
}
/// Superscript / subscript size and baseline offset (fractions of the font size).
pub const SCRIPT_SIZE: f32 = 0.583;
pub const SCRIPT_OFFSET: f32 = 0.333;
/// 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,
/// Optical kerning (spacing from the outlines; replaces metric kerning).
pub optical: bool,
/// Manual kerning before every character of the run, in 1/1000 em (replaces metrics).
pub manual_kern: Option<f32>,
/// 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,
/// Horizontal / vertical scale (1 = 100%).
pub h_scale: f32,
pub v_scale: f32,
/// Tsume: fraction (0–1) of the space around each glyph removed.
pub tsume: f32,
pub script: Script,
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
pub leading: Option<f32>,
/// OpenType features (stylistic sets, figures, fractions, all small caps…).
pub opentype: OpenType,
/// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar
/// phantom points) and outlines use this design-space position.
pub variations: Vec<(String, f32)>,
}
impl Default for TextStyle {
fn default() -> Self {
Self {
family: fonts::DEFAULT_FAMILY.into(),
style: "Regular".into(),
size: 100.0,
tracking: 0.0,
kerning: true,
optical: false,
manual_kern: None,
ligatures: true,
baseline_shift: 0.0,
faux_bold: false,
faux_italic: false,
caps: Caps::Normal,
underline: false,
h_scale: 1.0,
v_scale: 1.0,
tsume: 0.0,
script: Script::Normal,
leading: None,
opentype: OpenType::default(),
variations: Vec::new(),
}
}
}
impl TextStyle {
/// The size glyphs are drawn at (superscript / subscript are smaller).
pub fn glyph_size(&self) -> f32 {
match self.script {
Script::Normal => self.size,
_ => self.size * SCRIPT_SIZE,
}
}
/// Upward baseline offset: baseline shift plus the superscript / subscript offset.
pub fn rise(&self) -> f32 {
self.baseline_shift
+ match self.script {
Script::Normal => 0.0,
Script::Super => self.size * SCRIPT_OFFSET,
Script::Sub => -self.size * SCRIPT_OFFSET,
}
}
/// The line advance this style asks for.
pub fn line_advance(&self) -> f32 {
self.leading.unwrap_or(self.size * 1.2)
}
}
/// Paragraph formatting.
#[derive(Clone, Debug, PartialEq)]
pub struct ParagraphStyle {
pub align: Align,
/// How the last line of a justified paragraph is aligned (`Justify` = justify it too).
pub justify_last: Align,
/// Extra line spacing in pixels added to every line advance.
pub leading: f32,
/// Wrap width (area text); None = point text. Taken from the first paragraph.
pub width: Option<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
/// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent.
pub indent_start: f32,
pub indent_end: f32,
pub indent_first: f32,
pub space_before: f32,
pub space_after: f32,
/// Every-line composer (balanced breaks) instead of single-line (greedy).
pub every_line: bool,
/// Roman hanging punctuation.
pub hanging: bool,
}
impl Default for ParagraphStyle {
fn default() -> Self {
Self {
align: Align::Left,
justify_last: Align::Left,
leading: 0.0,
width: None,
rtl: None,
indent_start: 0.0,
indent_end: 0.0,
indent_first: 0.0,
space_before: 0.0,
space_after: 0.0,
every_line: false,
hanging: false,
}
}
}
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);
for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] {
hf(h, v);
}
self.manual_kern.map(f32::to_bits).hash(h);
self.leading.map(f32::to_bits).hash(h);
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
self.opentype.hash(h);
for (t, v) in &self.variations {
t.hash(h);
hf(h, *v);
}
}
}
impl Hash for ParagraphStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
(self.align, self.justify_last).hash(h);
for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] {
hf(h, v);
}
self.width.map(f32::to_bits).hash(h);
(self.rtl, self.every_line, self.hanging).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,
/// Horizontal / vertical scale of the outline (1 = 100%).
pub h_scale: f32,
pub v_scale: f32,
/// Index of the style run the glyph belongs to.
pub run: usize,
/// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the
/// default instance): outlines are drawn there.
pub variations: u32,
}
/// 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)>,
/// Paragraph index.
pub para: usize,
/// The line ends its paragraph.
pub para_end: bool,
}
#[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;
};
self.hit_line(l, x)
}
/// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret
/// before its trailing space, so clicking past it doesn't land on the next line.
pub fn hit_line(&self, l: &Line, x: f32) -> usize {
let end = self.line_end(l);
l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
}
/// The last caret stop of a line (before a wrapped line's trailing space).
pub fn line_end(&self, l: &Line) -> usize {
if l.para_end {
return l.range.end;
}
l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end)
}
/// 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 x0 > x1 {
x1 = l.x;
}
if b > l.range.end && l.para_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,
/// Space added before the glyph (manual / optical kerning).
pre: f32,
dx: f32,
dy: f32,
size: f32,
run: usize,
/// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's).
rise: 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;
/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital),
/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior
/// glyph (`sups` / `subs`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Form {
Normal,
FauxSmall,
TrueSmall,
TrueScript,
}
/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the
/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback.
pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool {
static C: OnceLock<Mutex<HashMap<(FaceId, [u8; 4], char), bool>>> = OnceLock::new();
let cache = C.get_or_init(Default::default);
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) {
return *v;
}
let f = fonts::face(face);
let v = f.has_feature(tag)
&& match (f.font(), f.shaper_data()) {
(Some(font), Some(data)) => {
let shaper = data.shaper(&font).build();
let shape = |feats: &[Feature]| -> Vec<u32> {
let mut buf = UnicodeBuffer::new();
buf.add(c, 0);
buf.guess_segment_properties();
shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect()
};
let plain = shape(&[]);
let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]);
plain != with && !with.contains(&0)
}
_ => false,
};
let mut m = cache.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 100_000 {
m.clear();
}
m.insert((face, *tag, c), v);
v
}
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
let p = crate::outline_units(face, gid)?;
if p.elements().is_empty() {
return None;
}
let r = kurbo::Shape::bounding_box(&*p);
Some((r.x0 as f32, r.x1 as f32))
}
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let instance = (!style.variations.is_empty()).then(|| {
harfrust::ShaperInstance::from_variations(
&font,
style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }),
)
});
let shaper = data.shaper(&font).instance(instance.as_ref()).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 || style.optical || style.manual_kern.is_some() {
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, ..));
}
// OpenType options; small caps go per character (true where the font has them).
for (tag, v) in style.opentype.feature_settings() {
if &tag != b"smcp" && &tag != b"c2sc" {
feats.push(Feature::new(Tag::new(&tag), v, ..));
}
}
if style.caps == Caps::All {
// Case-sensitive forms (punctuation raised to sit with capitals).
feats.push(Feature::new(Tag::new(b"case"), 1, ..));
}
let rise = match form {
Form::TrueScript => style.baseline_shift,
_ => style.rise(),
};
match form {
Form::TrueSmall => {
feats.push(Feature::new(Tag::new(b"smcp"), 1, ..));
if style.opentype.all_small_caps {
feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..));
}
}
Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)),
_ => {}
}
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
let k = size / f.units_per_em();
let hs = style.h_scale;
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 * hs,
pre: 0.0,
dx: p.x_offset as f32 * k * hs,
dy: p.y_offset as f32 * k,
size,
run,
rise,
})
.collect()
}
/// Tsume, tracking, manual and optical kerning on one shaped item (logical order).
fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) {
let side = |g: &ShapedGlyph| -> Option<(f32, f32)> {
let (x0, x1) = ink_x(g.face, g.id)?;
let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale;
Some((x0 * k, g.adv - x1 * k))
};
let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace);
if style.tsume > 0.0 {
let t = style.tsume.clamp(0.0, 1.0);
for g in glyphs.iter_mut() {
if is_space(g) {
continue;
}
if let Some((lsb, rsb)) = side(g) {
g.dx -= t * lsb.max(0.0);
g.adv -= t * (lsb.max(0.0) + rsb.max(0.0));
}
}
}
if style.optical && glyphs.len() > 1 {
// Pull every pair's ink gap halfway toward the item's median gap.
let sides: Vec<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
let mut sorted: Vec<f32> = gaps.iter().flatten().copied().collect();
sorted.sort_by(f32::total_cmp);
if let Some(&median) = sorted.get(sorted.len() / 2) {
for (i, gap) in gaps.iter().enumerate() {
if let Some(gap) = gap
&& glyphs[i + 1].cluster != glyphs[i].cluster
{
glyphs[i + 1].pre += (median - gap) * 0.5;
}
}
}
}
if let Some(m) = style.manual_kern {
let k = m * style.size / 1000.0;
for i in 0..glyphs.len() {
let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster;
if first_of_cluster && !(para_start && glyphs[i].cluster == 0) {
glyphs[i].pre += k;
}
}
}
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;
}
}
}
}
struct ParaLine {
range: Range<usize>,
glyphs: Vec<Glyph>,
carets: Vec<(usize, f32)>,
content: f32,
left_trim: f32,
/// Hanging punctuation widths at the left / right edge.
hang: (f32, f32),
ascent: f32,
descent: f32,
/// Line advance (largest leading on the line).
advance: f32,
align: Align,
avail: Option<f32>,
margins: (f32, f32),
underline: Option<(f32, f32)>,
}
fn is_hanging_close(c: char) -> bool {
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}')
}
fn is_hanging_open(c: char) -> bool {
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
}
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
/// paragraphs at the end).
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
};
let avail_for = |first: bool| -> Option<f32> {
let (l, r) = margins_for(first);
width.map(|w| (w - l - r).max(1.0))
};
let style_metrics = |si: usize| {
let st = &runs[si].1;
fonts::face(primaries[si].face).metrics(st.glyph_size())
};
let eff_align = |last: bool| -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
} else {
para.align
}
};
if text.is_empty() {
let si = run_at(runs, base);
let pm = style_metrics(si);
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
hang: (0.0, 0.0),
ascent: pm.ascent,
descent: pm.descent,
advance: runs[si].1.line_advance(),
align: eff_align(true),
avail: avail_for(true),
margins: margins_for(true),
underline: None,
}];
}
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, rtl, form, run)
let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text
.char_indices()
.map(|(b, c)| {
let si = run_at(runs, base + b);
let style = &runs[si].1;
let primary = primaries[si].face;
let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) };
let all_small = style.opentype.all_small_caps && style.caps != Caps::All;
let small = (style.caps == Caps::Small || all_small) && c.is_lowercase();
let small_cap = all_small && c.is_uppercase();
let (sc, mut form) = match style.caps {
Caps::All => (upper_single(c), Form::Normal),
_ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall),
_ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall),
_ if small => (upper_single(c), Form::FauxSmall),
_ if small_cap => (c, Form::FauxSmall),
_ => (c, Form::Normal),
};
let face = face_of(sc);
if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() {
let tag = if style.script == Script::Sub { b"subs" } else { b"sups" };
if feature_substitutes(face, tag, sc) {
form = Form::TrueScript;
}
}
(b, c, sc, face, bidi.levels[b].is_rtl(), form, si)
})
.collect();
// items: runs of equal (face, rtl, small, style)
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6);
let k0 = key(&chars[i]);
let mut j = i + 1;
while j < chars.len() && key(&chars[j]) == k0 {
j += 1;
}
let (face, rtl, form, si) = k0;
let style = &runs[si].1;
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
let size = match form {
Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE,
Form::TrueScript => style.size,
_ => style.glyph_size(),
};
let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form);
space_item(&mut glyphs, style, text, true);
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 + g.pre;
}
}
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)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
if !para.hanging || width.is_none() || r.start >= ce {
return (0.0, 0.0);
}
let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]);
let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]);
(first, last)
};
let fits_w = |r: &Range<usize>| -> f32 {
let (ce, _) = trailing_ws(r);
let (hl, hr) = hang_of(r, ce);
range_w(r.start..ce) - hl - hr
};
// line breaking
let mut ranges: Vec<Range<usize>> = Vec::new();
match width {
None => ranges.push(0..text.len()),
Some(_) => {
let opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect();
let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX);
if para.every_line && opps.len() > 1 && opps.len() <= 1500 {
// Minimise the summed squared slack of every line but the last.
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
let m = nodes.len();
let mut best = vec![(f32::INFINITY, 0usize); m];
best[0] = (0.0, 0);
for j in 1..m {
for i in (0..j).rev() {
if !best[i].0.is_finite() {
continue;
}
let r = nodes[i]..nodes[j];
let w = fits_w(&r);
let maxw = avail(usize::from(i != 0));
let over = w > maxw;
if over && i + 1 != j {
break;
}
let slack = maxw - w;
let cost = if over {
1e12 + w
} else if j == m - 1 {
0.0
} else {
slack * slack
};
let total = best[i].0 + cost;
if total < best[j].0 {
best[j] = (total, i);
}
}
}
let mut j = m - 1;
while j > 0 {
let i = best[j].1;
ranges.push(nodes[i]..nodes[j]);
j = i;
}
ranges.reverse();
} else {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for p in opps {
loop {
let w = fits_w(&(start..p));
if w <= avail(ranges.len()) || 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 last = li + 1 == nlines;
let align = eff_align(last);
let avail = avail_for(li == 0);
let (ce, tw) = trailing_ws(&r);
let hang = hang_of(&r, ce);
let content = range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if align == Align::Justify
&& let Some(maxw) = avail
{
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
}
}
let (levels, vruns) = 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();
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
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 st = &runs[g.run].1;
let x0 = pen;
pen += g.pre;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - g.rise,
size: g.size,
cluster: base + g.cluster,
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
synth_italic: primaries[g.run].synth_italic || st.faux_italic,
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: crate::variable::intern(&st.variations),
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
if st.underline && g.cluster < ce {
let u = underline.get_or_insert((x0, pen));
u.0 = u.0.min(x0);
u.1 = u.1.max(pen);
}
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);
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
seen.push(chars.first().map_or(0, |c| c.6));
}
for &si in &seen {
let m = style_metrics(si);
let rise = runs[si].1.rise();
asc = asc.max(m.ascent + rise.max(0.0));
desc = desc.max(m.descent - rise.min(0.0));
adv = adv.max(runs[si].1.line_advance());
}
for g in &glyphs {
if g.face != primaries[g.run].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 { avail.map_or(content, |a| a + hang.0 + hang.1) } 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 },
hang,
ascent: asc,
descent: desc,
advance: adv,
align,
avail,
margins: margins_for(li == 0),
underline,
});
}
out
}
/// Lay out `text` (paragraphs separated by `\n`) in one style.
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap
/// width is the first paragraph's.
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
let fallback = [(0..text.len(), TextStyle::default())];
let runs = if runs.is_empty() { &fallback[..] } else { runs };
let default_para = ParagraphStyle::default();
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
let width = para_at(0).width;
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut base = 0usize;
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
for (pi, p) in text.split('\n').enumerate() {
let p_clean = p.strip_suffix('\r').unwrap_or(p);
plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
base += p.len() + 1;
}
let mut baseline = 0.0f32;
for (i, (pi, pl)) in plines.into_iter().enumerate() {
let para = para_at(pi);
if i == 0 {
baseline = if width.is_some() { pl.ascent } else { 0.0 };
} else {
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
let mut adv = pl.advance + para.leading;
if prev_para != pi {
adv += para_at(prev_para).space_after + para.space_before;
}
baseline += adv;
}
let shown = pl.content - pl.hang.0 - pl.hang.1;
let (ml, mr) = pl.margins;
let shift = match (pl.avail, pl.align) {
(None, Align::Left | Align::Justify) => ml,
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
(None, Align::Right) => -shown - mr,
(Some(_), Align::Left | Align::Justify) => ml,
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
(Some(a), Align::Right) => ml + a - shown,
} - pl.left_trim
- pl.hang.0;
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 let Some((u0, u1)) = pl.underline {
let si = run_at(runs, pl.range.start);
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, 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(),
para: pi,
para_end: false,
});
}
for i in 0..lay.lines.len() {
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
}
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) = width {
x0 = x0.min(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` in one style (cached by text and styles).
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
runs.hash(&mut h);
paras.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_rich_uncached(text, runs, paras));
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() }
}
fn ids(text: &str, s: &TextStyle) -> Vec<u32> {
layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect()
}
fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle {
let mut s = st(100.0);
f(&mut s.opentype);
s
}
fn serif() -> TextStyle {
TextStyle { family: "Noto Serif".into(), ..st(100.0) }
}
#[test]
fn bundled_faces_list_their_features() {
let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features();
for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] {
assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}");
}
let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features();
for t in ["smcp", "c2sc", "onum", "lnum"] {
assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}");
}
}
#[test]
fn opentype_features_substitute_glyphs() {
let plain = st(100.0);
// Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures.
assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits");
assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20");
assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac");
assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn");
let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular));
let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional));
assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}");
// Old-style figures (Noto Serif).
let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() };
assert_ne!(ids("123", &serif()), ids("123", &old), "onum");
// Contextual alternates are on by default; turning them off can only keep or change glyphs.
let no_calt = ot(|o| o.contextual_alternates = false);
assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len());
}
#[test]
fn true_small_caps_and_superscripts_with_faux_fallback() {
// Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size.
let sc = TextStyle { caps: Caps::Small, ..serif() };
let l = layout_uncached("Ab", &sc, &ParagraphStyle::default());
let upper_b = ids("B", &serif())[0];
assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital");
assert_eq!(l.glyphs[1].size, 100.0);
// Inter has no smcp: a capital scaled down.
let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default());
assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]);
assert!((faux.glyphs[1].size - 78.0).abs() < 0.01);
// All Small Caps: capitals become small capitals too (c2sc).
let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() };
let a = layout_uncached("AB", &all, &ParagraphStyle::default());
assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]);
// Superscript: Inter's sups figures at full size on the baseline; letters it lacks are
// synthesized (smaller and raised).
let sup = TextStyle { script: Script::Super, ..st(100.0) };
let s2 = layout_uncached("2", &sup, &ParagraphStyle::default());
assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]);
assert_eq!(s2.glyphs[0].size, 100.0);
assert_eq!(s2.glyphs[0].y, 0.0);
let sq = layout_uncached("q", &sup, &ParagraphStyle::default());
if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') {
assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]);
}
let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default());
assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id);
}
#[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]);
}
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
layout_rich_uncached(text, runs, &[para])
}
#[test]
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
let text = "small BIG\nnext";
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
let l = rich(text, &runs, ParagraphStyle::default());
assert_eq!(l.lines.len(), 2);
let y0 = l.glyphs[0].y;
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
assert!(l.lines[0].ascent > 60.0);
// A big run on the second line pushes it down by its 120% leading.
let text2 = "aa\nBB";
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
// Explicit leading wins over auto.
let mut fixed = st(20.0);
fixed.leading = Some(50.0);
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
}
#[test]
fn super_subscript_tsume_and_kerning_metrics() {
let base = st(100.0);
let sup = TextStyle { script: Script::Super, ..base.clone() };
let sub = TextStyle { script: Script::Sub, ..base.clone() };
// A letter the font has no superior / inferior glyph for: synthesized.
let inter = fonts::resolve("Inter", "Regular").face;
let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap();
let text: String = ['x', c, 'y', c].iter().collect();
let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
let g = &l.glyphs;
assert!((g[1].size - 58.3).abs() < 0.1);
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
// Tsume removes side bearings; full tsume is narrower than none.
let w0 = measure("onion", &base);
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
// Horizontal scale stretches advances.
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
assert!((wide - 2.0 * w0).abs() < 0.5);
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
}
#[test]
fn indents_and_paragraph_spacing() {
let s = st(20.0);
let text = "first paragraph here\nsecond one";
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
assert!(l.lines[0].para_end && l.lines[1].para_end);
// Wrapped lines after the first don't get the first-line indent.
let long = "word ".repeat(30);
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
assert!(l.lines.len() > 2);
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
assert!(!l.lines[0].para_end);
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
}
#[test]
fn justify_last_line_modes_and_every_line_composer() {
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
let s = st(30.0);
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
let n = left.lines.len();
assert!(n >= 3);
assert!((left.lines[0].width - 320.0).abs() < 0.5);
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
let lc = &center.lines[n - 1];
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
let lr = &right.lines[n - 1];
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
// Every-line composer: never worse (summed squared slack) than greedy breaking.
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
}
#[test]
fn hanging_punctuation_overhangs_the_box() {
let s = st(40.0);
let text = "\"Hi.\"";
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
let r = layout_rich_uncached(
text,
&[(0..text.len(), s)],
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
);
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert_eq!(xs.len(), 4);
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
// Hit testing returns the logical position nearest the point.
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
}
}