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, TextAlign, TextStyle};
use crate::theme::Theme;
use crate::tokens::{FamilyRole, tracking_em};
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];
#[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(crate) fn resolve_text(ts: &TextStyle, theme: &Theme) -> ResolvedText {
let px = ts.size_px.unwrap_or_else(|| 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,
}
}
#[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,
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,
#[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(
&mut self,
text: &str,
style: &ResolvedText,
max_advance: Option<f32>,
) -> Layout<LayoutBrush> {
let max_advance = max_advance.and_then(|w| w.is_finite().then(|| w.clamp(0.0, 1.0e9)));
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),
)));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_advance);
let alignment = match style.align {
TextAlign::Start => Alignment::Start,
TextAlign::Center => Alignment::Center,
TextAlign::End => Alignment::End,
};
layout.align(alignment, AlignmentOptions::default());
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(&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(&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 = max_advance.and_then(|w| w.is_finite().then(|| w.clamp(0.0, 1.0e9)));
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),
)));
}
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 {
builder.push(StyleProperty::FontSize(px), 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);
let alignment = match style.align {
TextAlign::Start => Alignment::Start,
TextAlign::Center => Alignment::Center,
TextAlign::End => Alignment::End,
};
layout.align(alignment, AlignmentOptions::default());
layout
}
pub(crate) fn measure_rich(
&mut self,
spans: &[crate::element::Span],
style: &ResolvedText,
max_advance: Option<f32>,
) -> (f32, f32) {
let layout = self.shape_rich(spans, style, max_advance);
(layout.width().ceil(), 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 layout = self.layout(text, style, max_advance);
(layout.width().ceil(), 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("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_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));
}
}