use std::sync::OnceLock;
use fframes::{FontQuery, Frame};
use crate::core::Fonts;
use crate::meter::TextMeter;
pub struct FrameRef<'f> {
frame: &'f mut Frame,
}
impl<'f> FrameRef<'f> {
pub fn new(frame: &'f mut Frame) -> Self {
Self { frame }
}
pub fn frame(&self) -> &Frame {
self.frame
}
pub fn frame_mut(&mut self) -> &mut Frame {
self.frame
}
pub fn text_width<'a>(
&mut self,
ctx: &fframes::FFramesContext<'a, '_>,
query: FontQuery<'a>,
value: &'a str,
) -> Option<usize> {
self.frame.text_width(ctx, query, value)
}
}
impl std::fmt::Debug for FrameRef<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FrameRef")
.field("index", &self.frame.index)
.finish()
}
}
#[derive(Debug, Clone, Copy)]
pub struct ComponentFrame {
pub local: f32,
pub global: usize,
pub fps: f32,
pub duration: usize,
pub width: f32,
pub height: f32,
}
impl ComponentFrame {
pub fn local_index(&self) -> usize {
self.local.max(0.0) as usize
}
pub fn seconds(&self) -> f32 {
self.local / self.fps
}
}
#[derive(Debug, Clone)]
pub struct FaceMeter {
families: Vec<String>,
weight: u16,
meter: TextMeter,
}
impl FaceMeter {
pub fn probe(meter: TextMeter) -> Self {
let mut families: Vec<String> = Vec::new();
for family in [meter.family_name(), meter.legacy_family_name()]
.into_iter()
.flatten()
{
let family = family.trim().to_owned();
if !family.is_empty() && !families.iter().any(|known| known == &family) {
families.push(family);
}
}
Self {
families,
weight: meter.weight(),
meter,
}
}
pub fn families(&self) -> &[String] {
&self.families
}
pub fn family(&self) -> &str {
self.families.first().map_or("", String::as_str)
}
pub fn weight(&self) -> u16 {
self.weight
}
pub fn meter(&self) -> &TextMeter {
&self.meter
}
pub fn matches_family(&self, family: &str) -> bool {
let family = family.trim();
self.families
.iter()
.any(|known| known.eq_ignore_ascii_case(family))
}
fn weight_distance(&self, weight: u16) -> (u16, u8) {
(weight.abs_diff(self.weight), u8::from(self.weight > weight))
}
}
#[derive(Debug, Default)]
pub struct TextMeters {
pub sans: Option<TextMeter>,
pub mono: Option<TextMeter>,
faces: Vec<FaceMeter>,
}
impl TextMeters {
pub fn from_bytes(sans: Option<Vec<u8>>, mono: Option<Vec<u8>>) -> Self {
Self {
sans: sans.and_then(|bytes| TextMeter::new("sans", bytes)),
mono: mono.and_then(|bytes| TextMeter::new("mono", bytes)),
faces: Vec::new(),
}
}
pub fn with_face(mut self, meter: TextMeter) -> Self {
self.faces.push(FaceMeter::probe(meter));
self
}
pub fn faces(&self) -> &[FaceMeter] {
&self.faces
}
pub fn for_family(&self, family: &str, fonts: &Fonts) -> Option<&TextMeter> {
if let Some(face) = self.nearest_face(family, 400) {
return Some(face.meter());
}
if family == fonts.mono {
self.mono.as_ref().or(self.sans.as_ref())
} else {
self.sans.as_ref()
}
}
fn nearest_face(&self, family: &str, weight: u16) -> Option<&FaceMeter> {
self.faces
.iter()
.filter(|face| face.matches_family(family))
.min_by_key(|face| face.weight_distance(weight))
}
pub fn for_family_weight(
&self,
family: &str,
weight: u16,
fonts: &Fonts,
) -> Option<&TextMeter> {
match self.nearest_face(family, weight) {
Some(face) => Some(face.meter()),
None => self.for_family(family, fonts),
}
}
}
pub struct RenderCtx<'a> {
pub frame: FrameRef<'a>,
pub fonts: Fonts,
pub meters: &'a TextMeters,
pub info: ComponentFrame,
}
impl<'a> RenderCtx<'a> {
pub fn new(
frame: &'a mut Frame,
fonts: Fonts,
meters: &'a TextMeters,
info: ComponentFrame,
) -> Self {
Self {
frame: FrameRef::new(frame),
fonts,
meters,
info,
}
}
pub fn width(&self) -> f32 {
self.info.width
}
pub fn height(&self) -> f32 {
self.info.height
}
pub fn frame(&self) -> f32 {
self.info.local
}
pub fn vmin(&self) -> f32 {
self.width().min(self.height()) / 100.0
}
pub fn sans(&self) -> String {
self.fonts.sans.clone()
}
pub fn mono(&self) -> String {
self.fonts.mono.clone()
}
pub fn meter(&self, family: &str) -> Option<&TextMeter> {
self.meters.for_family(family, &self.fonts)
}
pub fn meter_at(&self, family: &str, weight: u16) -> Option<&TextMeter> {
self.meters.for_family_weight(family, weight, &self.fonts)
}
pub fn text_width(&self, family: &str, size: f32, text: &str) -> f32 {
match self.meter(family) {
Some(meter) => meter.text_width(text, size),
None => text.chars().count() as f32 * size * 0.55,
}
}
pub fn space_width(&self, family: &str, size: f32) -> f32 {
match self.meter(family) {
Some(meter) => meter.space_width(size),
None => size * 0.26,
}
}
pub fn ascent(&self, family: &str, size: f32) -> f32 {
match self.meter(family) {
Some(meter) => meter.ascent(size),
None => size * 0.75,
}
}
pub fn text_width_at(&self, family: &str, weight: u16, size: f32, text: &str) -> f32 {
match self.meter_at(family, weight) {
Some(meter) => meter.text_width(text, size),
None => text.chars().count() as f32 * size * 0.55,
}
}
pub fn space_width_at(&self, family: &str, weight: u16, size: f32) -> f32 {
match self.meter_at(family, weight) {
Some(meter) => meter.space_width(size),
None => size * 0.26,
}
}
pub fn ascent_at(&self, family: &str, weight: u16, size: f32) -> f32 {
match self.meter_at(family, weight) {
Some(meter) => meter.ascent(size),
None => size * 0.75,
}
}
pub fn baseline_offset(&self, family: &str, size: f32, line_height: f32) -> f32 {
let Some(meter) = self.meter(family) else {
return size * 0.34;
};
let ascent = meter.ascent(size);
let descent = meter.descent(size);
(line_height - (ascent + descent)) / 2.0 + ascent
}
pub fn baseline_offset_at(
&self,
family: &str,
weight: u16,
size: f32,
line_height: f32,
) -> f32 {
let Some(meter) = self.meter_at(family, weight) else {
return size * 0.34;
};
let ascent = meter.ascent(size);
let descent = meter.descent(size);
(line_height - (ascent + descent)) / 2.0 + ascent
}
}
pub fn words(text: &str) -> Vec<&str> {
text.split_whitespace().collect()
}
pub fn chars(text: &str) -> Vec<char> {
text.chars().collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlacedWord {
pub text: String,
pub x: f32,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlacedLine {
pub words: Vec<PlacedWord>,
pub width: f32,
}
impl PlacedLine {
pub fn left(&self, center_x: f32) -> f32 {
center_x - self.width / 2.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineLayout {
pub lines: Vec<PlacedLine>,
pub line_step: f32,
pub first_baseline_dy: f32,
}
impl LineLayout {
pub fn baseline_y(&self, index: usize, center_y: f32) -> f32 {
center_y + self.first_baseline_dy + index as f32 * self.line_step
}
pub fn all_words(&self, center_y: f32) -> Vec<(usize, &PlacedWord, f32)> {
let mut out = Vec::new();
let mut index = 0;
for (line_index, line) in self.lines.iter().enumerate() {
let y = self.baseline_y(line_index, center_y);
for word in &line.words {
out.push((index, word, y));
index += 1;
}
}
out
}
pub fn word_count(&self) -> usize {
self.lines.iter().map(|line| line.words.len()).sum()
}
}
#[derive(Debug, Default)]
pub struct LayoutCache(OnceLock<LineLayout>);
impl LayoutCache {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self) -> Option<&LineLayout> {
self.0.get()
}
pub fn resolve(
&self,
ctx: &RenderCtx<'_>,
family: &str,
text: &str,
font_size: f32,
max_width: f32,
line_height: f32,
) -> LineLayout {
if let Some(layout) = self.0.get() {
return layout.clone();
}
let layout = layout_paragraph(ctx, family, text, font_size, max_width, line_height);
let _ = self.0.set(layout.clone());
layout
}
}
impl Clone for LayoutCache {
fn clone(&self) -> Self {
Self(OnceLock::new())
}
}
pub fn layout_paragraph(
ctx: &RenderCtx<'_>,
family: &str,
text: &str,
font_size: f32,
max_width: f32,
line_height: f32,
) -> LineLayout {
let raw: Vec<&str> = text.split_whitespace().collect();
let line_step = font_size * line_height;
if raw.is_empty() {
return LineLayout {
lines: Vec::new(),
line_step,
first_baseline_dy: 0.0,
};
}
let space = ctx.space_width(family, font_size);
let mut lines: Vec<PlacedLine> = Vec::new();
let mut current: Vec<PlacedWord> = Vec::new();
let mut current_width = 0.0_f32;
for word in raw {
let width = ctx.text_width(family, font_size, word);
if current.is_empty() {
current_width = width;
current.push(PlacedWord {
text: word.to_owned(),
x: 0.0,
width,
});
continue;
}
if current_width + space + width > max_width {
lines.push(finish_line(std::mem::take(&mut current), space));
current_width = width;
} else {
current_width += space + width;
}
current.push(PlacedWord {
text: word.to_owned(),
x: 0.0,
width,
});
}
if !current.is_empty() {
lines.push(finish_line(current, space));
}
let line_count = lines.len();
let block_height = line_step * line_count as f32;
let first_baseline_dy = -block_height / 2.0 + ctx.baseline_offset(family, font_size, line_step);
LineLayout {
lines,
line_step,
first_baseline_dy,
}
}
fn finish_line(mut words: Vec<PlacedWord>, space: f32) -> PlacedLine {
let total: f32 =
words.iter().map(|w| w.width).sum::<f32>() + space * words.len().saturating_sub(1) as f32;
let mut cursor = -total / 2.0;
for word in &mut words {
word.x = cursor + word.width / 2.0;
cursor += word.width + space;
}
PlacedLine {
words,
width: total,
}
}
pub fn centered_positions(sizes: &[f32], count: usize, gap: f32) -> Vec<f32> {
if count == 0 || sizes.is_empty() {
return Vec::new();
}
let count = count.min(sizes.len());
let placed = &sizes[..count];
let total: f32 = placed.iter().sum::<f32>() + gap * (count.saturating_sub(1)) as f32;
let mut cursor = -total / 2.0;
placed
.iter()
.map(|size| {
let center = cursor + size / 2.0;
cursor += size + gap;
center
})
.collect()
}
pub fn char_centers(ctx: &RenderCtx<'_>, family: &str, line: &str, size: f32) -> (Vec<f32>, f32) {
let glyphs: Vec<char> = line.chars().collect();
let widths: Vec<f32> = glyphs
.iter()
.map(|ch| {
let mut buffer = [0_u8; 4];
ctx.text_width(family, size, ch.encode_utf8(&mut buffer))
})
.collect();
let total: f32 = widths.iter().sum();
let mut cursor = -total / 2.0;
let centres = widths
.iter()
.map(|width| {
let centre = cursor + width / 2.0;
cursor += width;
centre
})
.collect();
(centres, total)
}
pub fn interpolate3(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t <= 0.5 { t * 2.0 } else { (1.0 - t) * 2.0 }
}
pub fn svg_transform(x: f32, y: f32, scale: f32, rotate_deg: f32) -> String {
let mut parts = Vec::new();
if x.abs() > 1e-4 || y.abs() > 1e-4 {
parts.push(format!("translate({x:.4} {y:.4})"));
}
if (scale - 1.0).abs() > 1e-5 {
parts.push(format!("scale({scale:.6})"));
}
if rotate_deg.abs() > 1e-4 {
parts.push(format!("rotate({rotate_deg:.4})"));
}
if parts.is_empty() {
"none".to_owned()
} else {
parts.join(" ")
}
}
pub fn escape_xml(value: &str) -> std::borrow::Cow<'_, str> {
if value
.bytes()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
{
let mut out = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(value)
}
}
pub fn centred_text(text: &str, center_x: f32, font_size: f32, tracking_em: f32) -> (String, f32) {
let chars = text.chars().count().max(1);
let correction = -tracking_em * font_size * chars as f32 / 2.0;
(text.to_owned(), center_x + correction)
}
#[must_use]
pub fn font_weight_attr(weight: u16) -> String {
weight.to_string()
}
#[allow(clippy::too_many_arguments)]
pub fn text_node_centered(
content: &str,
center_x: f32,
y: f32,
family: String,
font_size: f32,
weight: u16,
tracking_em: f32,
fill: String,
opacity: f32,
) -> fframes::Svgr<'static> {
let (content, x) = centred_text(content, center_x, font_size, tracking_em);
let weight = font_weight_attr(weight);
fframes::svgr!(
<text x={x} y={y} font-family={family} font-size={font_size} font-weight={weight}
letter-spacing={tracking_em} fill={fill} opacity={opacity}
text-anchor="middle">{content}</text>
)
}
#[allow(clippy::too_many_arguments)]
pub fn text_node_left(
content: String,
x: f32,
y: f32,
family: String,
font_size: f32,
weight: u16,
tracking_em: f32,
fill: String,
opacity: f32,
) -> fframes::Svgr<'static> {
let weight = font_weight_attr(weight);
fframes::svgr!(
<text x={x} y={y} font-family={family} font-size={font_size} font-weight={weight}
letter-spacing={tracking_em} fill={fill} opacity={opacity}>{content}</text>
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_splits_like_the_source() {
assert_eq!(words("Hello, World!"), vec!["Hello,", "World!"]);
assert_eq!(words(" a b "), vec!["a", "b"]);
assert!(words(" ").is_empty());
}
#[test]
fn centered_positions_matches_the_ported_formula() {
let sizes = [10.0, 20.0, 30.0];
let all = centered_positions(&sizes, 3, 4.0);
assert_eq!(all.len(), 3);
assert!((all[0] + 29.0).abs() < 1e-5, "got {}", all[0]);
assert!((all[1] + 10.0).abs() < 1e-5, "got {}", all[1]);
assert!((all[2] - 19.0).abs() < 1e-5, "got {}", all[2]);
assert!((centered_positions(&sizes, 1, 4.0)[0]).abs() < 1e-5);
}
#[test]
fn finish_line_centres_words() {
let words = vec![
PlacedWord {
text: "a".into(),
x: 0.0,
width: 10.0,
},
PlacedWord {
text: "b".into(),
x: 0.0,
width: 10.0,
},
];
let line = finish_line(words, 4.0);
assert!((line.width - 24.0).abs() < 1e-5);
assert!((line.words[0].x + 7.0).abs() < 1e-5);
assert!((line.words[1].x - 7.0).abs() < 1e-5);
}
#[test]
fn interpolate3_pulses() {
assert!((interpolate3(0.0) - 0.0).abs() < 1e-6);
assert!((interpolate3(0.5) - 1.0).abs() < 1e-6);
assert!((interpolate3(1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn escape_xml_only_escapes_when_needed() {
assert_eq!(escape_xml("plain"), "plain");
assert_eq!(escape_xml("a & b"), "a & b");
assert_eq!(escape_xml("<x>"), "<x>");
}
#[test]
fn tracking_correction_centres_a_negative_track() {
let (_, x) = centred_text("hello", 100.0, 72.0, -0.03);
assert!(x > 100.0, "expected a rightward correction, got {x}");
let (_, x0) = centred_text("hello", 100.0, 72.0, 0.0);
assert!((x0 - 100.0).abs() < 1e-6);
}
#[test]
fn baseline_offsets_step_by_line_height() {
let layout = LineLayout {
lines: Vec::new(),
line_step: 80.0,
first_baseline_dy: 10.0,
};
assert!((layout.baseline_y(0, 540.0) - 550.0).abs() < 1e-6);
assert!((layout.baseline_y(2, 540.0) - 710.0).abs() < 1e-6);
}
#[test]
fn all_words_indexes_across_line_breaks() {
let line = |text: &str, x: f32| PlacedLine {
words: vec![PlacedWord {
text: text.to_owned(),
x,
width: 10.0,
}],
width: 10.0,
};
let layout = LineLayout {
lines: vec![line("alpha", 0.0), line("beta", 0.0), line("gamma", 0.0)],
line_step: 80.0,
first_baseline_dy: 10.0,
};
let words = layout.all_words(540.0);
assert_eq!(words.len(), 3);
assert_eq!(
words.iter().map(|(index, ..)| *index).collect::<Vec<_>>(),
vec![0, 1, 2]
);
assert_eq!(words[0].2, 550.0);
assert_eq!(words[1].2, 630.0);
assert_eq!(words[2].2, 710.0);
}
#[test]
fn caches_are_sync() {
fn assert_sync<T: Sync>() {}
assert_sync::<LayoutCache>();
assert_sync::<TextMeters>();
}
fn embedded(file: &str) -> TextMeter {
let bytes = std::fs::read(format!("{}/media/{file}", env!("CARGO_MANIFEST_DIR")))
.unwrap_or_else(|e| panic!("media/{file} is in the repo: {e}"));
TextMeter::new(file, bytes).expect("the embedded font parses")
}
fn registry() -> TextMeters {
let dm_sans = std::fs::read(format!(
"{}/media/DMSans-Medium.ttf",
env!("CARGO_MANIFEST_DIR")
))
.expect("the original embedded font is still in the repo");
let mut meters = TextMeters::from_bytes(Some(dm_sans.clone()), Some(dm_sans));
for file in [
"Inter-Regular.ttf",
"Inter-Medium.ttf",
"Inter-SemiBold.ttf",
"Inter-Bold.ttf",
"Montserrat-Bold.ttf",
"Montserrat-ExtraBold.ttf",
"Montserrat-Black.ttf",
] {
meters = meters.with_face(embedded(file));
}
meters
}
fn family_of<'m>(meters: &'m TextMeters, family: &str, weight: u16) -> &'m str {
meters
.for_family_weight(family, weight, &Fonts::default())
.expect("the family is registered")
.name()
}
#[test]
fn every_face_is_registered_under_one_weighted_family() {
let meters = registry();
let fonts = Fonts::default();
let inter: Vec<(String, u16)> = meters
.faces()
.iter()
.filter(|face| face.matches_family("Inter"))
.map(|face| (face.family().to_owned(), face.weight()))
.collect();
assert_eq!(inter.len(), 4, "all four Inter instances are registered");
assert!(
inter.iter().all(|(family, _)| family == "Inter"),
"the renderer resolves every Inter instance as \"Inter\": {inter:?}"
);
assert_eq!(
inter.iter().map(|(_, weight)| *weight).collect::<Vec<_>>(),
vec![400, 500, 600, 700]
);
assert_eq!(family_of(&meters, "Inter", 600), "Inter-SemiBold.ttf");
assert_eq!(family_of(&meters, "Inter", 400), "Inter-Regular.ttf");
assert_eq!(family_of(&meters, "Inter", 700), "Inter-Bold.ttf");
assert_eq!(
family_of(&meters, "Montserrat", 900),
"Montserrat-Black.ttf"
);
assert_eq!(family_of(&meters, "Montserrat", 700), "Montserrat-Bold.ttf");
assert_eq!(family_of(&meters, "DM Sans", 700), "mono");
assert_eq!(family_of(&meters, "DM Sans", 400), "mono");
assert_eq!(family_of(&meters, "Comic Sans", 400), "sans");
assert!(meters.for_family_weight("Inter", 400, &fonts).is_some());
}
#[test]
fn a_weighted_family_measures_as_its_own_regular_face() {
let meters = registry();
let fonts = Fonts::default();
assert_eq!(
meters.for_family("Inter", &fonts).map(TextMeter::name),
Some("Inter-Regular.ttf")
);
assert_eq!(
meters.for_family("Montserrat", &fonts).map(TextMeter::name),
Some("Montserrat-Bold.ttf"),
"Montserrat has no 400, so its lightest face is the regular one"
);
assert_eq!(
meters.for_family("inter", &fonts).map(TextMeter::name),
Some("Inter-Regular.ttf")
);
assert_eq!(
meters.for_family("DM Sans", &fonts).map(TextMeter::name),
Some("mono")
);
}
#[test]
fn nearest_weight_resolves_ties_downward() {
let meters = registry();
let fonts = Fonts::default();
let at = |weight| {
meters
.for_family_weight("Montserrat", weight, &fonts)
.expect("registered")
.name()
};
assert_eq!(at(750), "Montserrat-Bold.ttf");
assert_eq!(at(849), "Montserrat-ExtraBold.ttf");
assert_eq!(at(850), "Montserrat-ExtraBold.ttf");
assert_eq!(at(851), "Montserrat-Black.ttf");
assert_eq!(at(100), "Montserrat-Bold.ttf");
assert_eq!(at(1000), "Montserrat-Black.ttf");
assert_eq!(at(0), "Montserrat-Bold.ttf");
}
#[test]
fn a_weighted_measurement_uses_the_weighted_face() {
let meters = registry();
let width = |family: &str, weight: u16| {
meters
.for_family_weight(family, weight, &Fonts::default())
.expect("registered")
.text_width("Hamburgefonstiv", 100.0)
};
let regular = width("Inter", 400);
assert!(
width("Inter", 700) > regular,
"Inter Bold should measure wider than Inter Regular: {} vs {regular}",
width("Inter", 700)
);
assert!(
width("Montserrat", 900) > width("Montserrat", 700),
"Montserrat Black should measure wider than Montserrat Bold"
);
assert!(
(width("DM Sans", 400) - width("DM Sans", 900)).abs() < f32::EPSILON,
"DM Sans has one face, so its weight cannot change the measurement"
);
}
}