use std::cmp::Ordering;
use std::collections::HashMap;
use fontique::{Blob, CollectionOptions, GenericFamily};
use kurbo::{Affine, Rect};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, Layout, LayoutContext,
LineHeight, PositionedLayoutItem, StyleProperty,
};
use peniko::{Color, Fill};
use vello::Scene;
use crate::style::{FontFeatures, OpticalSizing, TextAlign, TextStyle, TextWrap};
use crate::theme::Theme;
use crate::tokens::{FamilyRole, tracking_em};
const WRAP_GRID: f32 = 0.25;
const INTER_REGULAR: &[u8] = include_bytes!("../assets/inter/Inter-Regular.otf");
const INTER_MEDIUM: &[u8] = include_bytes!("../assets/inter/Inter-Medium.otf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/inter/Inter-SemiBold.otf");
const MONO_FAMILIES: &[&str] = &["SF Mono", "Cascadia Code", "JetBrains Mono"];
pub(crate) type LayoutBrush = [u8; 4];
fn clamp_advance(max_advance: Option<f32>) -> Option<f32> {
max_advance.and_then(|w| w.is_finite().then(|| w.clamp(0.0, 1.0e9)))
}
const MAX_FONT_PX: f32 = 4096.0;
fn sane_font_px(px: f32, fallback: f32) -> f32 {
if px.is_finite() {
px.clamp(0.0, MAX_FONT_PX)
} else {
fallback
}
}
fn last_line_word_count(layout: &Layout<LayoutBrush>, text: &str) -> usize {
layout
.lines()
.last()
.map(|l| text[l.text_range()].split_whitespace().count())
.unwrap_or(0)
}
fn apply_align(layout: &mut Layout<LayoutBrush>, align: TextAlign) {
let alignment = match align {
TextAlign::Start => Alignment::Start,
TextAlign::Center => Alignment::Center,
TextAlign::End => Alignment::End,
};
layout.align(alignment, AlignmentOptions::default());
}
fn box_width(layout: &Layout<LayoutBrush>, upper: Option<f32>) -> f32 {
let lma = layout.layout_max_advance();
match upper {
Some(u) if lma.is_finite() && lma < u - 0.5 => lma.ceil(),
_ => layout.width().ceil(),
}
}
fn rebalance(layout: &mut Layout<LayoutBrush>, style: &ResolvedText, upper: f32) {
let n = layout.len();
if n < 2 {
return; }
let lo = layout.calculate_content_widths().min; if lo >= upper {
return;
}
let mut lo_w = (lo / WRAP_GRID).floor() * WRAP_GRID; let mut hi_w = (upper / WRAP_GRID).ceil() * WRAP_GRID; while hi_w - lo_w > WRAP_GRID {
let mid = ((lo_w + hi_w) * 0.5 / WRAP_GRID).round() * WRAP_GRID;
layout.break_all_lines(Some(mid));
if layout.len() <= n {
hi_w = mid;
} else {
lo_w = mid;
}
}
layout.break_all_lines(Some(hi_w));
apply_align(layout, style.align); }
fn prettify(layout: &mut Layout<LayoutBrush>, text: &str, style: &ResolvedText, upper: f32) {
let n = layout.len();
if n < 2 || last_line_word_count(layout, text) >= 2 {
return; }
let lo = layout.calculate_content_widths().min;
if lo >= upper {
return;
}
let mut w = (upper / WRAP_GRID).floor() * WRAP_GRID;
let mut fixed = None;
while w > lo {
layout.break_all_lines(Some(w));
match layout.len().cmp(&n) {
Ordering::Equal if last_line_word_count(layout, text) >= 2 => {
fixed = Some(w);
break;
}
Ordering::Greater => break, _ => {}
}
w -= WRAP_GRID;
}
layout.break_all_lines(Some(fixed.unwrap_or(upper)));
apply_align(layout, style.align);
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ResolvedText {
pub px: f32,
pub weight: f32,
pub line_height: f32,
pub letter_spacing: f32,
pub family: FamilyRole,
pub align: TextAlign,
pub max_lines: Option<u32>,
pub color: Color,
pub features: FontFeatures,
pub wrap: TextWrap,
pub optical: OpticalSizing,
}
pub(crate) fn resolve_text(ts: &TextStyle, theme: &Theme) -> ResolvedText {
let px = sane_font_px(ts.size_px.unwrap_or_else(|| ts.size.px()), ts.size.px());
ResolvedText {
px,
weight: ts.weight.value(),
line_height: ts.line_height.unwrap_or_else(|| {
if ts.size_px.is_some() {
1.25
} else {
ts.size.line_height()
}
}),
letter_spacing: ts.letter_spacing.unwrap_or_else(|| tracking_em(px)) * px,
family: ts.family,
align: ts.align,
max_lines: ts.max_lines,
color: ts.color.unwrap_or(theme.text),
features: ts.features,
wrap: ts.wrap,
optical: ts.optical,
}
}
fn opsz_source(opsz: f32) -> String {
format!("\"opsz\" {opsz}")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct LayoutKey {
text: String,
px: u32,
weight: u32,
line_height: u32,
letter_spacing: u32,
family: FamilyRole,
align: TextAlign,
max_lines: Option<u32>,
features: FontFeatures,
wrap: TextWrap,
opsz: u32,
width_bucket: u32,
}
impl LayoutKey {
fn new(text: &str, style: &ResolvedText, max_advance: Option<f32>) -> Self {
Self {
text: text.to_owned(),
px: style.px.to_bits(),
weight: style.weight.to_bits(),
line_height: style.line_height.to_bits(),
letter_spacing: style.letter_spacing.to_bits(),
family: style.family,
align: style.align,
max_lines: style.max_lines,
features: style.features,
wrap: style.wrap,
opsz: match style.optical.opsz_at(style.px) {
Some(v) => v.to_bits(),
None => u32::MAX,
},
#[expect(clippy::cast_sign_loss, reason = "advances are non-negative")]
width_bucket: match max_advance {
Some(w) => (w.max(0.0) * 4.0).round() as u32,
None => u32::MAX,
},
}
}
}
pub struct Fonts {
font_cx: FontContext,
layout_cx: LayoutContext<LayoutBrush>,
cache: HashMap<LayoutKey, Layout<LayoutBrush>>,
roles: HashMap<FamilyRole, String>,
}
impl Fonts {
pub fn embedded() -> Self {
Self::new(false)
}
pub fn with_system() -> Self {
Self::new(true)
}
fn new(system_fonts: bool) -> Self {
let mut font_cx = FontContext {
collection: fontique::Collection::new(CollectionOptions {
shared: false,
system_fonts,
}),
source_cache: fontique::SourceCache::default(),
};
let collection = &mut font_cx.collection;
let mut inter_ids = Vec::new();
for bytes in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD] {
let data = Blob::new(std::sync::Arc::new(bytes));
for (family, _fonts) in collection.register_fonts(data, None) {
if !inter_ids.contains(&family) {
inter_ids.push(family);
}
}
}
let mut sans = inter_ids.clone();
sans.extend(collection.generic_families(GenericFamily::SansSerif));
collection.set_generic_families(GenericFamily::SansSerif, sans.into_iter());
let mut mono: Vec<_> = MONO_FAMILIES
.iter()
.filter_map(|name| collection.family_id(name))
.collect();
mono.extend(collection.generic_families(GenericFamily::Monospace));
mono.extend(inter_ids.iter().copied());
collection.set_generic_families(GenericFamily::Monospace, mono.into_iter());
Self {
font_cx,
layout_cx: LayoutContext::new(),
cache: HashMap::new(),
roles: HashMap::new(),
}
}
pub fn register(&mut self, role: FamilyRole, data: Vec<u8>) -> bool {
let collection = &mut self.font_cx.collection;
let mut name = None;
for (family, _fonts) in
collection.register_fonts(Blob::new(std::sync::Arc::new(data)), None)
{
if name.is_none() {
name = collection.family_name(family).map(str::to_owned);
}
}
let Some(name) = name else {
return false;
};
self.roles.insert(role, name);
self.cache.clear();
true
}
pub(crate) fn editor_contexts(
&mut self,
) -> (&mut FontContext, &mut LayoutContext<LayoutBrush>) {
(&mut self.font_cx, &mut self.layout_cx)
}
pub(crate) fn layout(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> &Layout<LayoutBrush> {
let key = LayoutKey::new(text, style, max_advance);
if !self.cache.contains_key(&key) {
let layout = self.build_layout(text, style, max_advance);
self.cache.insert(key.clone(), layout);
}
&self.cache[&key]
}
fn build_layout(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> Layout<LayoutBrush> {
let mut layout = self.shape(text, style, max_advance);
if let (Some(max_lines), Some(_)) = (style.max_lines, max_advance) {
let max_lines = max_lines.max(1) as usize;
if layout.lines().count() > max_lines {
layout = self.truncate(text, style, max_advance, max_lines);
}
}
layout
}
fn shape_greedy(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> Layout<LayoutBrush> {
let max_advance = clamp_advance(max_advance);
let family = match self.roles.get(&style.family) {
Some(name) => FontFamily::named(name),
None => match style.family {
FamilyRole::Mono => FontFamily::Single(GenericFamily::Monospace.into()),
_ => FontFamily::named("Inter"),
},
};
let mut builder = self
.layout_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(family));
builder.push_default(StyleProperty::FontSize(style.px));
builder.push_default(StyleProperty::FontWeight(FontWeight::new(style.weight)));
builder.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
style.line_height,
)));
builder.push_default(StyleProperty::LetterSpacing(style.letter_spacing));
if let Some(s) = style.features.feature_string() {
builder.push_default(StyleProperty::FontFeatures(parley::FontFeatures::Source(
std::borrow::Cow::Owned(s),
)));
}
if let Some(opsz) = style.optical.opsz_at(style.px) {
builder.push_default(StyleProperty::FontVariations(
parley::FontVariations::Source(std::borrow::Cow::Owned(opsz_source(opsz))),
));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_advance);
apply_align(&mut layout, style.align);
layout
}
fn shape(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> Layout<LayoutBrush> {
let upper = clamp_advance(max_advance);
let mut layout = self.shape_greedy(text, style, max_advance);
if let Some(upper) = upper {
match style.wrap {
TextWrap::Normal => {}
TextWrap::Balance => rebalance(&mut layout, style, upper),
TextWrap::Pretty => prettify(&mut layout, text, style, upper),
}
}
layout
}
fn truncate(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
max_lines: usize,
) -> Layout<LayoutBrush> {
let boundaries: Vec<usize> = text
.char_indices()
.map(|(i, _)| i)
.chain([text.len()])
.collect();
let fits = |fonts: &mut Self, end: usize| {
let candidate = format!("{}\u{2026}", text[..end].trim_end());
fonts
.shape_greedy(&candidate, style, max_advance)
.lines()
.count()
<= max_lines
};
let (mut lo, mut hi) = (0_usize, boundaries.len() - 1);
while lo < hi {
let mid = (lo + hi).div_ceil(2);
if fits(self, boundaries[mid]) {
lo = mid;
} else {
hi = mid - 1;
}
}
let candidate = format!("{}\u{2026}", text[..boundaries[lo]].trim_end());
self.shape_greedy(&candidate, style, max_advance)
}
fn shape_rich(
&mut self,
spans: &[crate::element::Span],
style: &ResolvedText,
max_advance: Option<f32>,
) -> Layout<LayoutBrush> {
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
let base_brush = style.color.to_rgba8().to_u8_array();
let resolve_family =
|role: FamilyRole, roles: &std::collections::HashMap<FamilyRole, String>| {
roles.get(&role).cloned().unwrap_or_else(|| match role {
FamilyRole::Mono => String::new(), _ => "Inter".to_owned(),
})
};
let base_family = resolve_family(style.family, &self.roles);
let span_families: Vec<Option<String>> = spans
.iter()
.map(|s| s.family.map(|f| resolve_family(f, &self.roles)))
.collect();
fn to_family(name: &str) -> FontFamily<'_> {
if name.is_empty() {
FontFamily::Single(GenericFamily::Monospace.into())
} else {
FontFamily::named(name)
}
}
let max_advance = clamp_advance(max_advance);
let mut builder = self
.layout_cx
.ranged_builder(&mut self.font_cx, &text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(to_family(&base_family)));
builder.push_default(StyleProperty::FontSize(style.px));
builder.push_default(StyleProperty::FontWeight(FontWeight::new(style.weight)));
builder.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
style.line_height,
)));
builder.push_default(StyleProperty::LetterSpacing(style.letter_spacing));
if let Some(s) = style.features.feature_string() {
builder.push_default(StyleProperty::FontFeatures(parley::FontFeatures::Source(
std::borrow::Cow::Owned(s),
)));
}
if let Some(opsz) = style.optical.opsz_at(style.px) {
builder.push_default(StyleProperty::FontVariations(
parley::FontVariations::Source(std::borrow::Cow::Owned(opsz_source(opsz))),
));
}
builder.push_default(StyleProperty::Brush(base_brush));
let mut start = 0usize;
for (i, span) in spans.iter().enumerate() {
let range = start..start + span.text.len();
start = range.end;
if let Some(weight) = span.weight {
builder.push(
StyleProperty::FontWeight(FontWeight::new(weight.value())),
range.clone(),
);
}
if let Some(px) = span.size_px {
let px = sane_font_px(px, style.px);
builder.push(StyleProperty::FontSize(px), range.clone());
if matches!(style.optical, OpticalSizing::Auto)
&& let Some(opsz) = style.optical.opsz_at(px)
{
builder.push(
StyleProperty::FontVariations(parley::FontVariations::Source(
std::borrow::Cow::Owned(opsz_source(opsz)),
)),
range.clone(),
);
}
}
if let Some(color) = span.color {
builder.push(
StyleProperty::Brush(color.to_rgba8().to_u8_array()),
range.clone(),
);
}
if let Some(name) = &span_families[i] {
builder.push(StyleProperty::FontFamily(to_family(name)), range.clone());
}
if span.italic {
builder.push(
StyleProperty::FontStyle(parley::FontStyle::Italic),
range.clone(),
);
}
}
let mut layout = builder.build(&text);
layout.break_all_lines(max_advance);
apply_align(&mut layout, style.align);
if let Some(upper) = max_advance {
match style.wrap {
TextWrap::Normal => {}
TextWrap::Balance => rebalance(&mut layout, style, upper),
TextWrap::Pretty => prettify(&mut layout, &text, style, upper),
}
}
layout
}
pub(crate) fn measure_rich(
&mut self,
spans: &[crate::element::Span],
style: &ResolvedText,
max_advance: Option<f32>,
) -> (f32, f32) {
let upper = clamp_advance(max_advance);
let layout = self.shape_rich(spans, style, max_advance);
(box_width(&layout, upper), layout.height().ceil())
}
pub(crate) fn first_baseline_rich(
&mut self,
spans: &[crate::element::Span],
style: &ResolvedText,
max_advance: Option<f32>,
) -> f32 {
let layout = self.shape_rich(spans, style, max_advance);
layout
.lines()
.next()
.map_or(0.0, |line| line.metrics().baseline)
}
pub(crate) fn paint_rich(
&mut self,
scene: &mut Scene,
spans: &[crate::element::Span],
style: &ResolvedText,
rect: Rect,
selection: Option<(parley::Selection, Color)>,
) {
#[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
let max_advance = Some(rect.width() as f32);
let transform = Affine::translate((rect.x0, rect.y0));
if let Some((sel, highlight)) = selection {
for r in self.static_selection_rects(
&crate::frame::StaticText::Rich(spans),
style,
max_advance,
sel,
) {
scene.fill(Fill::NonZero, transform, highlight, None, &r);
}
}
let layout = self.shape_rich(spans, style, max_advance);
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let [r, g, b, a] = glyph_run.style().brush;
let color = Color::from_rgba8(r, g, b, a);
let mut x = glyph_run.offset();
let y = glyph_run.baseline();
let run = glyph_run.run();
let glyph_xform = run
.synthesis()
.skew()
.map(|angle| Affine::skew(f64::from(angle.to_radians().tan()), 0.0));
scene
.draw_glyphs(run.font())
.brush(color)
.hint(true)
.transform(transform)
.glyph_transform(glyph_xform)
.font_size(run.font_size())
.normalized_coords(run.normalized_coords())
.draw(
Fill::NonZero,
glyph_run.glyphs().map(|glyph| {
let gx = x + glyph.x;
let gy = y + glyph.y;
x += glyph.advance;
vello::Glyph {
id: glyph.id,
x: gx,
y: gy,
}
}),
);
}
}
}
fn static_layout(
&mut self,
text: &crate::frame::StaticText<'_>,
style: &ResolvedText,
max_advance: Option<f32>,
) -> std::borrow::Cow<'_, Layout<LayoutBrush>> {
match text {
crate::frame::StaticText::Plain(s) => {
std::borrow::Cow::Borrowed(self.layout(s, style, max_advance))
}
crate::frame::StaticText::Rich(spans) => {
std::borrow::Cow::Owned(self.shape_rich(spans, style, max_advance))
}
}
}
pub(crate) fn static_select(
&mut self,
text: &crate::frame::StaticText<'_>,
style: &ResolvedText,
max_advance: Option<f32>,
count: u8,
x: f32,
y: f32,
) -> parley::Selection {
let layout = self.static_layout(text, style, max_advance);
match count {
2 => parley::Selection::word_from_point(layout.as_ref(), x, y),
3 => parley::Selection::line_from_point(layout.as_ref(), x, y),
_ => parley::Selection::from_point(layout.as_ref(), x, y),
}
}
pub(crate) fn static_extend(
&mut self,
text: &crate::frame::StaticText<'_>,
style: &ResolvedText,
max_advance: Option<f32>,
sel: parley::Selection,
x: f32,
y: f32,
) -> parley::Selection {
let layout = self.static_layout(text, style, max_advance);
sel.extend_to_point(layout.as_ref(), x, y)
}
pub(crate) fn static_selection_rects(
&mut self,
text: &crate::frame::StaticText<'_>,
style: &ResolvedText,
max_advance: Option<f32>,
sel: parley::Selection,
) -> Vec<Rect> {
let layout = self.static_layout(text, style, max_advance);
sel.geometry(layout.as_ref())
.into_iter()
.map(|(bb, _)| Rect::new(bb.x0, bb.y0, bb.x1, bb.y1))
.collect()
}
pub(crate) fn measure(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> (f32, f32) {
let upper = clamp_advance(max_advance);
let layout = self.layout(text, style, max_advance);
(box_width(layout, upper), layout.height().ceil())
}
pub(crate) fn ch_width(&mut self, style: &ResolvedText) -> f32 {
let mut zero = *style;
zero.letter_spacing = 0.0;
let layout = self.shape_greedy("0", &zero, None);
let mut advance = 0.0_f32;
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(run) = item {
for glyph in run.glyphs() {
advance += glyph.advance;
}
}
}
}
advance
}
pub(crate) fn first_baseline(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> f32 {
let layout = self.layout(text, style, max_advance);
layout
.lines()
.next()
.map_or(0.0, |line| line.metrics().baseline)
}
pub(crate) fn paint(
&mut self,
scene: &mut Scene,
text: &str,
style: &ResolvedText,
rect: Rect,
selection: Option<(parley::Selection, Color)>,
) {
#[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
let max_advance = Some(rect.width() as f32);
let color = style.color;
let transform = Affine::translate((rect.x0, rect.y0));
if let Some((sel, highlight)) = selection {
for r in self.static_selection_rects(
&crate::frame::StaticText::Plain(text),
style,
max_advance,
sel,
) {
scene.fill(Fill::NonZero, transform, highlight, None, &r);
}
}
let layout = self.layout(text, style, max_advance);
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let mut x = glyph_run.offset();
let y = glyph_run.baseline();
let run = glyph_run.run();
let glyph_xform = run
.synthesis()
.skew()
.map(|angle| Affine::skew(f64::from(angle.to_radians().tan()), 0.0));
scene
.draw_glyphs(run.font())
.brush(color)
.hint(true)
.transform(transform)
.glyph_transform(glyph_xform)
.font_size(run.font_size())
.normalized_coords(run.normalized_coords())
.draw(
Fill::NonZero,
glyph_run.glyphs().map(|glyph| {
let gx = x + glyph.x;
let gy = y + glyph.y;
x += glyph.advance;
vello::Glyph {
id: glyph.id,
x: gx,
y: gy,
}
}),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{Length, Style, TextStyle};
use crate::tokens::{MEASURE_CH, TextSize};
fn rt(size: TextSize) -> ResolvedText {
resolve_text(
&TextStyle {
size,
..TextStyle::default()
},
&Theme::light(),
)
}
fn glyph_advance_sum(fonts: &mut Fonts, text: &str, style: &ResolvedText) -> f32 {
let layout = fonts.shape(text, style, None);
let mut advance = 0.0_f32;
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(run) = item {
for glyph in run.glyphs() {
advance += glyph.advance;
}
}
}
}
advance
}
#[test]
fn ch_width_matches_zero_advance() {
let mut fonts = Fonts::embedded();
let style = rt(TextSize::Base);
let ch = fonts.ch_width(&style);
assert!((9.5..=10.5).contains(&ch), "ch {ch}");
let mut zeroed = style;
zeroed.letter_spacing = 0.0;
let independent = glyph_advance_sum(&mut fonts, "0", &zeroed);
assert!((ch - independent).abs() < 1e-3, "ch {ch} vs {independent}");
}
#[test]
fn ch_width_ignores_letter_spacing() {
let mut fonts = Fonts::embedded();
let mut tight = rt(TextSize::Base);
tight.letter_spacing = -2.0;
let mut loose = rt(TextSize::Base);
loose.letter_spacing = 5.0;
assert!((fonts.ch_width(&tight) - fonts.ch_width(&loose)).abs() < 1e-3);
}
#[test]
fn resolve_ch_scales_by_zero_advance() {
let mut fonts = Fonts::embedded();
let ch = fonts.ch_width(&rt(TextSize::Base));
let mut style = Style::default().measure(MEASURE_CH);
style.resolve_ch(ch);
assert_eq!(style.max_width, Length::Px(MEASURE_CH * ch));
let px = MEASURE_CH * ch;
assert!((490.0..=560.0).contains(&px), "{MEASURE_CH}ch = {px}px");
}
#[test]
fn resolve_ch_tracks_text_size() {
let mut fonts = Fonts::embedded();
let ch_base = fonts.ch_width(&rt(TextSize::Base)); let ch_lg = fonts.ch_width(&rt(TextSize::Lg)); assert!(ch_lg > ch_base, "lg {ch_lg} base {ch_base}");
assert!(
(ch_lg - ch_base * 1.25).abs() < 0.5,
"lg {ch_lg} base {ch_base}"
);
}
#[test]
fn has_ch_only_flags_ch_lengths() {
assert!(!Style::default().has_ch());
assert!(Style::default().measure(MEASURE_CH).has_ch());
assert!(Style::default().w_ch(40.0).has_ch());
assert!(!Style::default().w(100.0).has_ch());
}
fn keys_differ(a: &ResolvedText, b: &ResolvedText) -> bool {
LayoutKey::new("0123456789", a, None) != LayoutKey::new("0123456789", b, None)
}
fn rt_feat(f: impl FnOnce(&mut crate::style::FontFeatures)) -> ResolvedText {
let mut style = rt(TextSize::Base);
f(&mut style.features);
style
}
#[test]
fn layout_key_differs_on_spacing() {
use crate::style::NumericSpacing::{Default, Proportional, Tabular};
let def = rt_feat(|x| x.spacing = Default);
let tab = rt_feat(|x| x.spacing = Tabular);
let prop = rt_feat(|x| x.spacing = Proportional);
assert!(keys_differ(&def, &tab));
assert!(keys_differ(&def, &prop));
assert!(keys_differ(&tab, &prop));
}
#[test]
fn layout_key_differs_on_figures() {
use crate::style::FigureStyle::{Default, Lining, OldStyle};
let def = rt_feat(|x| x.figures = Default);
let lin = rt_feat(|x| x.figures = Lining);
let old = rt_feat(|x| x.figures = OldStyle);
assert!(keys_differ(&def, &lin));
assert!(keys_differ(&def, &old));
assert!(keys_differ(&lin, &old));
}
#[test]
fn layout_key_differs_on_small_caps() {
let off = rt_feat(|x| x.small_caps = false);
let on = rt_feat(|x| x.small_caps = true);
assert!(keys_differ(&off, &on));
}
#[test]
fn layout_key_differs_on_ligatures() {
let def = rt_feat(|x| x.ligatures = None);
let off = rt_feat(|x| x.ligatures = Some(false));
let on = rt_feat(|x| x.ligatures = Some(true));
assert!(keys_differ(&def, &off));
assert!(keys_differ(&def, &on));
assert!(keys_differ(&off, &on));
}
#[test]
fn layout_key_differs_on_fractions() {
let off = rt_feat(|x| x.fractions = false);
let on = rt_feat(|x| x.fractions = true);
assert!(keys_differ(&off, &on));
}
#[test]
fn layout_key_differs_on_opsz() {
use crate::style::OpticalSizing::{Auto, Default, Fixed};
let rt_opt = |o: crate::style::OpticalSizing| {
let mut s = rt(TextSize::Base); s.optical = o;
s
};
let def = rt_opt(Default);
let auto = rt_opt(Auto); let fixed_big = rt_opt(Fixed(96.0));
let fixed_small = rt_opt(Fixed(12.0));
assert!(keys_differ(&def, &auto), "none vs 16");
assert!(keys_differ(&def, &fixed_big), "none vs 96");
assert!(keys_differ(&auto, &fixed_big), "16 vs 96");
assert!(keys_differ(&fixed_small, &fixed_big), "12 vs 96");
assert!(
!keys_differ(&auto, &rt_opt(Fixed(16.0))),
"auto@16 == fixed(16)"
);
}
#[test]
fn opsz_source_formats_clean() {
assert_eq!(opsz_source(16.0), "\"opsz\" 16");
assert_eq!(opsz_source(48.0), "\"opsz\" 48");
assert_eq!(opsz_source(9.5), "\"opsz\" 9.5");
}
#[test]
fn layout_key_equal_when_features_equal() {
let a = rt_feat(|x| {
x.spacing = crate::style::NumericSpacing::Tabular;
x.small_caps = true;
});
let b = rt_feat(|x| {
x.spacing = crate::style::NumericSpacing::Tabular;
x.small_caps = true;
});
assert!(!keys_differ(&a, &b));
}
fn rt_px(px: f32, wrap: TextWrap) -> ResolvedText {
resolve_text(
&TextStyle {
size_px: Some(px),
wrap,
..TextStyle::default()
},
&Theme::light(),
)
}
fn line_advances(layout: &Layout<LayoutBrush>) -> Vec<f32> {
layout
.lines()
.map(|l| {
let m = l.metrics();
m.advance - m.trailing_whitespace
})
.collect()
}
fn last_words(layout: &Layout<LayoutBrush>, text: &str) -> usize {
layout
.lines()
.last()
.map(|l| text[l.text_range()].split_whitespace().count())
.unwrap_or(0)
}
fn max_adv(advs: &[f32]) -> f32 {
advs.iter().copied().fold(0.0_f32, f32::max)
}
fn min_adv(advs: &[f32]) -> f32 {
advs.iter().copied().fold(f32::MAX, f32::min)
}
#[test]
fn balance_evens_a_two_line_heading() {
let mut fonts = Fonts::embedded();
let text = "Balanced headings keep their lines visually even";
let full = fonts
.shape(text, &rt_px(28.0, TextWrap::Normal), None)
.width();
let w = full * 0.62;
let greedy = fonts.shape(text, &rt_px(28.0, TextWrap::Normal), Some(w));
let bal = fonts.shape(text, &rt_px(28.0, TextWrap::Balance), Some(w));
assert_eq!(greedy.len(), 2, "precondition: greedy wraps to two lines");
assert_eq!(bal.len(), greedy.len(), "N preserved");
let g = line_advances(&greedy);
let b = line_advances(&bal);
assert!(
max_adv(&b) < max_adv(&g),
"balanced longest {} < greedy longest {}",
max_adv(&b),
max_adv(&g)
);
assert!(
(max_adv(&b) - min_adv(&b)) < (max_adv(&g) - min_adv(&g)),
"balanced spread {} < greedy spread {}",
max_adv(&b) - min_adv(&b),
max_adv(&g) - min_adv(&g)
);
}
#[test]
fn balance_single_line_is_noop() {
let mut fonts = Fonts::embedded();
let text = "Short title";
let full = fonts
.shape(text, &rt_px(28.0, TextWrap::Normal), None)
.width();
let w = full * 2.0; let normal = fonts.shape(text, &rt_px(28.0, TextWrap::Normal), Some(w));
let bal = fonts.shape(text, &rt_px(28.0, TextWrap::Balance), Some(w));
assert_eq!(normal.len(), 1);
assert_eq!(bal.len(), 1);
assert!((normal.width() - bal.width()).abs() < 1e-3);
}
#[test]
fn balance_preserves_line_count_never_overflows() {
let mut fonts = Fonts::embedded();
let text = "Balanced headings keep their lines visually even";
let full = fonts
.shape(text, &rt_px(28.0, TextWrap::Normal), None)
.width();
let w = full * 0.62;
let greedy = fonts.shape(text, &rt_px(28.0, TextWrap::Normal), Some(w));
let bal = fonts.shape(text, &rt_px(28.0, TextWrap::Balance), Some(w));
assert_eq!(bal.len(), greedy.len());
assert!(
max_adv(&line_advances(&bal)) <= w + 0.5,
"widest balanced line exceeds W {w}"
);
}
#[test]
fn pretty_pulls_word_onto_last_line() {
let mut fonts = Fonts::embedded();
let text = "Typesetters avoid leaving a single short word stranded alone here";
let style_n = rt_px(18.0, TextWrap::Normal);
let full = fonts.shape(text, &style_n, None).width();
let mut target = None;
let mut w = full;
while w > full * 0.2 {
let g = fonts.shape(text, &style_n, Some(w));
if g.len() >= 2 && last_words(&g, text) == 1 {
target = Some(w);
break;
}
w -= 1.0;
}
let w = target.expect("a width that strands the last word");
let greedy = fonts.shape(text, &style_n, Some(w));
let pretty = fonts.shape(text, &rt_px(18.0, TextWrap::Pretty), Some(w));
assert_eq!(pretty.len(), greedy.len(), "pretty adds no line");
assert!(
last_words(&pretty, text) >= 2,
"orphan pulled onto the last line"
);
}
#[test]
fn pretty_never_worse_when_no_orphan() {
let mut fonts = Fonts::embedded();
let text = "These words wrap into lines that each already end with several words";
let style_n = rt_px(18.0, TextWrap::Normal);
let full = fonts.shape(text, &style_n, None).width();
let mut chosen = None;
let mut w = full;
while w > full * 0.2 {
let g = fonts.shape(text, &style_n, Some(w));
if g.len() >= 2 && last_words(&g, text) >= 2 {
chosen = Some(w);
break;
}
w -= 1.0;
}
let w = chosen.expect("a width with a non-orphan last line");
let greedy = fonts.shape(text, &style_n, Some(w));
let pretty = fonts.shape(text, &rt_px(18.0, TextWrap::Pretty), Some(w));
assert_eq!(pretty.len(), greedy.len());
assert_eq!(last_words(&pretty, text), last_words(&greedy, text));
assert_eq!(
line_advances(&pretty),
line_advances(&greedy),
"no-op break"
);
}
#[test]
fn layout_key_differs_on_wrap() {
let mk = |wrap| {
let mut s = rt(TextSize::Base);
s.wrap = wrap;
s
};
let normal = mk(TextWrap::Normal);
let balance = mk(TextWrap::Balance);
let pretty = mk(TextWrap::Pretty);
assert!(keys_differ(&normal, &balance));
assert!(keys_differ(&normal, &pretty));
assert!(keys_differ(&balance, &pretty));
assert!(!keys_differ(&balance, &mk(TextWrap::Balance)));
}
#[test]
fn balance_idempotent_reproduces_break() {
let mut fonts = Fonts::embedded();
let text = "Balanced headings keep their lines visually even";
let full = fonts
.shape(text, &rt_px(28.0, TextWrap::Normal), None)
.width();
let w = full * 0.62;
let bal = fonts.shape(text, &rt_px(28.0, TextWrap::Balance), Some(w));
let bw = box_width(&bal, clamp_advance(Some(w)));
let bal2 = fonts.shape(text, &rt_px(28.0, TextWrap::Balance), Some(bw));
assert_eq!(bal.len(), bal2.len());
assert_eq!(line_advances(&bal), line_advances(&bal2));
}
#[test]
fn pretty_idempotent_reproduces_break() {
let mut fonts = Fonts::embedded();
let text = "Typesetters avoid leaving a single short word stranded alone here";
let style_n = rt_px(18.0, TextWrap::Normal);
let full = fonts.shape(text, &style_n, None).width();
let mut target = None;
let mut w = full;
while w > full * 0.2 {
let g = fonts.shape(text, &style_n, Some(w));
if g.len() >= 2 && last_words(&g, text) == 1 {
target = Some(w);
break;
}
w -= 1.0;
}
let w = target.expect("a width that strands the last word");
let pretty = fonts.shape(text, &rt_px(18.0, TextWrap::Pretty), Some(w));
let bw = box_width(&pretty, clamp_advance(Some(w)));
let pretty2 = fonts.shape(text, &rt_px(18.0, TextWrap::Pretty), Some(bw));
assert_eq!(pretty.len(), pretty2.len());
assert_eq!(line_advances(&pretty), line_advances(&pretty2));
}
}