filmcraft/crates/captions/src/font.rs

138 lines
4.6 KiB
Rust

//! The caption font: Inter SemiBold (SIL OFL 1.1, `assets/fonts/Inter-SemiBold.ttf`, attributed in
//! ATTRIBUTION.md), read with `skrifa` and rasterised by [`crate::raster`]. Glyph masks are
//! cached by (char, size, sub-pixel offset).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, MetadataProvider};
use crate::raster::{Mask, Path, fill};
static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
fn font() -> Option<FontRef<'static>> {
FontRef::new(INTER_SEMIBOLD).ok()
}
/// Vertical metrics at a pixel size: (ascent, descent), both positive.
pub fn metrics(px: f32) -> (f32, f32) {
match font() {
Some(f) => {
let m = f.metrics(Size::new(px), LocationRef::default());
(m.ascent, -m.descent)
}
None => (px * 0.8, px * 0.2),
}
}
/// Horizontal advance of a character at a pixel size.
pub fn advance(c: char, px: f32) -> f32 {
let Some(f) = font() else { return px * 0.5 };
let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'));
gid.and_then(|g| f.glyph_metrics(Size::new(px), LocationRef::default()).advance_width(g)).unwrap_or(px * 0.5)
}
/// Width of a single line of text.
pub fn measure(text: &str, px: f32) -> f32 {
text.chars().map(|c| advance(c, px)).sum()
}
struct Pen {
p: Path,
}
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.p.move_to(x, -y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.p.line_to(x, -y);
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.p.quad_to(cx0, -cy0, x, -y);
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.p.cubic_to(cx0, -cy0, cx1, -cy1, x, -y);
}
fn close(&mut self) {
self.p.close();
}
}
/// A rasterised glyph: coverage mask placed with its top-left at `(left, top)` relative to the
/// pen position on the baseline.
#[derive(Debug)]
pub struct Glyph {
pub mask: Mask,
pub left: i32,
pub top: i32,
}
type Key = (char, u32, u8);
fn cache() -> &'static Mutex<HashMap<Key, Option<Arc<Glyph>>>> {
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<Glyph>>>>> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Rasterise `c` at `px` with the pen at fractional x offset `frac` (0..1, quantised to ¼ px).
pub fn glyph(c: char, px: f32, frac: f32) -> Option<Arc<Glyph>> {
let size_q = (px * 4.0).round().max(1.0) as u32;
let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4;
let key = (c, size_q, frac_q);
if let Some(g) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return g.clone();
}
let g = render_glyph(c, size_q as f32 / 4.0, frac_q as f32 / 4.0).map(Arc::new);
let mut m = cache().lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8192 {
m.clear();
}
m.insert(key, g.clone());
g
}
fn render_glyph(c: char, px: f32, frac: f32) -> Option<Glyph> {
if c.is_whitespace() {
return None;
}
let f = font()?;
let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'))?;
let outline = f.outline_glyphs().get(gid)?;
let mut pen = Pen { p: Path::default() };
outline.draw(DrawSettings::unhinted(Size::new(px), LocationRef::default()), &mut pen).ok()?;
pen.p.close();
let (x0, y0, x1, y1) = pen.p.bounds()?;
let left = (x0 + frac).floor() as i32;
let top = y0.floor() as i32;
let w = ((x1 + frac).ceil() as i32 - left).max(1) as usize;
let h = (y1.ceil() as i32 - top).max(1) as usize;
let mask = fill(&pen.p, w, h, frac - left as f32, -top as f32);
Some(Glyph { mask, left, top })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inter_loads_and_draws() {
let (a, d) = metrics(100.0);
assert!(a > 80.0 && a < 110.0, "ascent {a}");
assert!(d > 10.0 && d < 40.0, "descent {d}");
assert!(advance('W', 100.0) > advance('i', 100.0));
let g = glyph('H', 40.0, 0.0).unwrap();
// the H has two stems: the middle row has ink on both sides and a gap between them
let y = g.mask.h / 4;
let row: Vec<f32> = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect();
assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}");
assert!(row[g.mask.w / 2] < 0.1, "{row:?}");
assert!(g.top < 0, "glyph rises above the baseline");
assert!(glyph(' ', 40.0, 0.0).is_none());
assert!(measure("Hello", 30.0) > 50.0);
}
}