#[cfg(feature = "google-fonts")]
pub mod google_fonts;
#[cfg(feature = "google-fonts")]
pub use google_fonts::{fetch_google_font, google_font_cache_dir, GoogleFontError};
pub mod rich;
pub(crate) mod shape_common;
use std::cell::RefCell;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use parley::{AlignmentOptions, FontContext, LayoutContext, PositionedLayoutItem};
use crate::brush::Brush;
use crate::geometry::{Affine, Point, Rect};
use crate::layout::{Measure, WidthHint};
use crate::pick::PickId;
use crate::scene::{Font, Glyph, GlyphRun, SceneBuilder};
use crate::shape::Shape;
use crate::style_vocab::HAlign;
type B = ();
fn halign_to_parley(align: HAlign) -> parley::Alignment {
match align {
HAlign::Start => parley::Alignment::Start,
HAlign::Center => parley::Alignment::Center,
HAlign::End => parley::Alignment::End,
HAlign::Justify => parley::Alignment::Justify,
}
}
pub(crate) fn font_context() -> &'static Mutex<FontContext> {
static FC: OnceLock<Mutex<FontContext>> = OnceLock::new();
FC.get_or_init(|| Mutex::new(FontContext::new()))
}
pub fn register_font_bytes(bytes: impl Into<Vec<u8>>) -> usize {
let owned: Arc<Vec<u8>> = Arc::new(bytes.into());
let blob = parley::fontique::Blob::new(owned);
let mut fcx = font_context().lock().expect("font context poisoned");
let registered = fcx.collection.register_fonts(blob, None);
registered.iter().map(|(_, fonts)| fonts.len()).sum()
}
pub fn register_font_path(path: impl AsRef<Path>) -> std::io::Result<usize> {
let bytes = std::fs::read(path)?;
Ok(register_font_bytes(bytes))
}
pub fn register_font_dir(dir: impl AsRef<Path>) -> std::io::Result<usize> {
let mut total = 0;
for entry in std::fs::read_dir(dir.as_ref())? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext_ok = path
.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"ttf" | "otf" | "ttc" | "otc"
)
})
.unwrap_or(false);
if ext_ok {
total += register_font_path(&path)?;
}
}
Ok(total)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GenericFamilyKind {
Serif,
SansSerif,
Mono,
Cursive,
Fantasy,
SystemUi,
}
#[derive(Clone, Debug, PartialEq)]
pub enum FontFamilyEntry {
Named(String),
Generic(GenericFamilyKind),
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum FontStyleKind {
#[default]
Normal,
Italic,
Oblique(f32),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FontFeatureSetting {
pub tag: [u8; 4],
pub value: u16,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FontVariationSetting {
pub tag: [u8; 4],
pub value: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LineHeight {
Relative(f32),
Absolute(f32),
}
impl Default for LineHeight {
fn default() -> Self {
LineHeight::Relative(1.2)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub size_pt: f32,
pub families: Vec<FontFamilyEntry>,
pub weight: u16,
pub width: f32,
pub style: FontStyleKind,
pub line_height: LineHeight,
pub letter_spacing_pt: f32,
pub underline: bool,
pub strikethrough: bool,
pub features: Vec<FontFeatureSetting>,
pub variations: Vec<FontVariationSetting>,
}
impl TextStyle {
pub fn new(size_pt: f32) -> Self {
Self {
size_pt,
families: Vec::new(),
weight: 400,
width: 1.0,
style: FontStyleKind::Normal,
line_height: LineHeight::default(),
letter_spacing_pt: 0.0,
underline: false,
strikethrough: false,
features: Vec::new(),
variations: Vec::new(),
}
}
pub fn family(mut self, name: impl Into<String>) -> Self {
self.families = vec![FontFamilyEntry::Named(name.into())];
self
}
pub fn families(mut self, entries: impl IntoIterator<Item = FontFamilyEntry>) -> Self {
self.families = entries.into_iter().collect();
self
}
pub fn generic_family(mut self, kind: GenericFamilyKind) -> Self {
self.families.push(FontFamilyEntry::Generic(kind));
self
}
pub fn weight(mut self, w: u16) -> Self {
self.weight = w;
self
}
pub fn width(mut self, ratio: f32) -> Self {
self.width = ratio;
self
}
pub fn italic(mut self, yes: bool) -> Self {
self.style = if yes {
FontStyleKind::Italic
} else {
FontStyleKind::Normal
};
self
}
pub fn style(mut self, kind: FontStyleKind) -> Self {
self.style = kind;
self
}
pub fn line_height(mut self, lh: LineHeight) -> Self {
self.line_height = lh;
self
}
pub fn letter_spacing_pt(mut self, pt: f32) -> Self {
self.letter_spacing_pt = pt;
self
}
pub fn underline(mut self, yes: bool) -> Self {
self.underline = yes;
self
}
pub fn strikethrough(mut self, yes: bool) -> Self {
self.strikethrough = yes;
self
}
pub fn features(mut self, items: impl IntoIterator<Item = FontFeatureSetting>) -> Self {
self.features = items.into_iter().collect();
self
}
pub fn variations(mut self, items: impl IntoIterator<Item = FontVariationSetting>) -> Self {
self.variations = items.into_iter().collect();
self
}
}
impl Default for TextStyle {
fn default() -> Self {
Self::new(14.0)
}
}
pub struct TextRun {
layout: RefCell<parley::Layout<B>>,
min_width: f32,
natural_width: f32,
natural_height: f32,
last_break_width: RefCell<Option<f32>>,
}
thread_local! {
static PLAIN_LAYOUT_CONTEXT: RefCell<LayoutContext<B>> =
RefCell::new(LayoutContext::new());
}
impl TextRun {
pub fn new(text: &str, style: &TextStyle, dpi: f64) -> Self {
let fcx_mutex = font_context();
let mut fcx = fcx_mutex.lock().expect("font context poisoned");
let (layout, widths, natural_height) = PLAIN_LAYOUT_CONTEXT.with(|lcx| {
let mut lcx = lcx.borrow_mut();
let mut builder = lcx.ranged_builder(&mut fcx, text, 1.0, true);
shape_common::push_style_defaults(&mut builder, style, dpi);
let mut layout: parley::Layout<B> = builder.build(text);
layout.break_all_lines(None);
layout.align(parley::Alignment::Start, AlignmentOptions::default());
let widths = layout.calculate_content_widths();
let natural_height = layout.height();
(layout, widths, natural_height)
});
Self {
layout: RefCell::new(layout),
min_width: widths.min,
natural_width: widths.max,
natural_height,
last_break_width: RefCell::new(None),
}
}
pub fn set_max_width(&self, max_width: f32, alignment: HAlign) -> f32 {
let mut layout = self.layout.borrow_mut();
layout.break_all_lines(Some(max_width));
layout.align(halign_to_parley(alignment), AlignmentOptions::default());
*self.last_break_width.borrow_mut() = Some(max_width);
layout.height()
}
pub fn natural_width(&self) -> f64 {
self.natural_width as f64
}
pub fn natural_height(&self) -> f64 {
self.natural_height as f64
}
pub fn current_height(&self) -> f64 {
self.layout.borrow().height() as f64
}
pub fn content_width(&self) -> f64 {
let layout = self.layout.borrow();
let mut max_w = 0.0_f32;
for line in layout.lines() {
let w = line.metrics().advance;
if w > max_w {
max_w = w;
}
}
max_w as f64
}
pub fn baseline_offset(&self) -> f64 {
let layout = self.layout.borrow();
let mut iter = layout.lines();
match iter.next() {
Some(line) => line.metrics().baseline as f64,
None => 0.0,
}
}
pub fn cap_height(&self) -> f64 {
let layout = self.layout.borrow();
let mut lines_iter = layout.lines();
let line = match lines_iter.next() {
Some(l) => l,
None => return 0.0,
};
let ascent_fallback = line.metrics().ascent as f64;
let mut result: Option<f64> = None;
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(gr) = item {
let m = gr.run().metrics();
if let Some(h) = m.cap_height.or(m.x_height) {
result = Some(h as f64);
break;
}
}
}
result.unwrap_or(ascent_fallback * 0.7)
}
pub fn last_line_descender(&self) -> f64 {
let layout = self.layout.borrow();
layout
.lines()
.last()
.map(|line| line.metrics().descent as f64)
.unwrap_or(0.0)
}
pub fn first_line_ascender_offset(&self) -> f64 {
let layout = self.layout.borrow();
let result = layout
.lines()
.next()
.map(|line| {
let m = line.metrics();
(m.baseline as f64) - (m.ascent as f64)
})
.unwrap_or(0.0);
result
}
pub fn last_line_descender_offset_from_top(&self) -> f64 {
let layout = self.layout.borrow();
let result = layout
.lines()
.last()
.map(|line| {
let m = line.metrics();
(m.baseline as f64) + (m.descent as f64)
})
.unwrap_or(0.0);
result
}
pub fn inked_height(&self) -> f64 {
(self.last_line_descender_offset_from_top() - self.first_line_ascender_offset()).max(0.0)
}
}
impl Measure for TextRun {
fn width_hint(&self, _dpi: f64) -> WidthHint {
WidthHint::Min(self.min_width as f64)
}
fn height_at(&self, width: f64, _dpi: f64) -> f64 {
let _ = self.set_max_width(width as f32, HAlign::Start);
self.inked_height()
}
}
#[derive(Clone)]
pub struct LaidGlyph {
pub id: u32,
pub x: f32,
pub y: f32,
pub advance: f32,
pub font: Font,
pub font_size: f32,
}
pub fn run_layout_glyphs(run: &TextRun) -> Vec<LaidGlyph> {
let layout = run.layout.borrow();
let mut out = Vec::new();
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(gr) = item else {
continue;
};
let prun = gr.run();
let font = Font::from_data(prun.font().clone());
let font_size = prun.font_size();
for g in gr.positioned_glyphs() {
out.push(LaidGlyph {
id: g.id,
x: g.x,
y: g.y,
advance: g.advance,
font: font.clone(),
font_size,
});
}
}
}
out
}
pub fn glyph_marker(text: &str, style: &TextStyle) -> Shape {
const PROBE_PX: f32 = 64.0;
const PROBE_PT: f32 = PROBE_PX * 72.0 / 96.0;
let probe = TextStyle {
size_pt: PROBE_PT,
families: style.families.clone(),
weight: style.weight,
width: style.width,
style: style.style,
line_height: style.line_height,
letter_spacing_pt: style.letter_spacing_pt,
underline: false,
strikethrough: false,
features: style.features.clone(),
variations: style.variations.clone(),
};
let run = TextRun::new(text, &probe, 96.0);
let laid = run_layout_glyphs(&run);
assert_eq!(
laid.len(),
1,
"glyph_marker({text:?}): shaped to {} glyphs, but markers require exactly 1 \
(multi-codepoint inputs must ligate to a single composite glyph in the resolved font)",
laid.len()
);
let g = &laid[0];
let s = PROBE_PX as f64;
let em_origin = Point::new(g.x as f64 / s, g.y as f64 / s);
let advance_em = run.content_width() / s;
let natural_h_em = run.natural_height() / s;
let centre_y = natural_h_em / 2.0;
let h = em_origin.y;
let em_bbox = Rect::new(0.0, centre_y - h / 2.0, advance_em, centre_y + h / 2.0);
let anchor = Point::new(-0.5, 0.0);
Shape::glyph(g.font.clone(), g.id, em_bbox, em_origin, anchor)
}
pub fn draw_text<S: SceneBuilder + ?Sized>(
scene: &mut S,
run: &TextRun,
x: f64,
y: f64,
brush: &Brush,
transform: Affine,
pick_id: PickId,
) {
let layout = run.layout.borrow();
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(gr) = item else {
continue; };
let prun = gr.run();
let font = Font::from_data(prun.font().clone());
let glyphs: Vec<Glyph> = gr
.positioned_glyphs()
.map(|g| Glyph {
id: g.id,
x: x as f32 + g.x,
y: y as f32 + g.y,
})
.collect();
if glyphs.is_empty() {
continue;
}
let glyph_run = GlyphRun {
font: &font,
font_size: prun.font_size(),
transform,
glyph_transform: None,
brush,
brush_alpha: 1.0,
hint: false,
glyphs: &glyphs,
style: None,
};
scene.draw_glyphs(&glyph_run, pick_id);
let style = gr.style();
let metrics = prun.metrics();
let baseline = gr.baseline();
let run_x0 = x as f32 + gr.offset();
let run_x1 = run_x0 + gr.advance();
if let Some(deco) = &style.underline {
emit_decoration_rect(
scene,
DecorationRect {
x0: run_x0,
x1: run_x1,
top: y as f32 + baseline - deco.offset.unwrap_or(metrics.underline_offset),
thickness: deco.size.unwrap_or(metrics.underline_size).max(0.0),
},
brush,
transform,
pick_id,
);
}
if let Some(deco) = &style.strikethrough {
emit_decoration_rect(
scene,
DecorationRect {
x0: run_x0,
x1: run_x1,
top: y as f32 + baseline
- deco.offset.unwrap_or(metrics.strikethrough_offset),
thickness: deco.size.unwrap_or(metrics.strikethrough_size).max(0.0),
},
brush,
transform,
pick_id,
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw_text_outline<S: SceneBuilder + ?Sized>(
scene: &mut S,
run: &TextRun,
x: f64,
y: f64,
stroke_brush: &Brush,
stroke: &crate::stroke::Stroke,
transform: Affine,
pick_id: PickId,
) {
let layout = run.layout.borrow();
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(gr) = item else {
continue;
};
let prun = gr.run();
let font = Font::from_data(prun.font().clone());
let glyphs: Vec<Glyph> = gr
.positioned_glyphs()
.map(|g| Glyph {
id: g.id,
x: x as f32 + g.x,
y: y as f32 + g.y,
})
.collect();
if glyphs.is_empty() {
continue;
}
let glyph_run = GlyphRun {
font: &font,
font_size: prun.font_size(),
transform,
glyph_transform: None,
brush: stroke_brush,
brush_alpha: 1.0,
hint: false,
glyphs: &glyphs,
style: Some(stroke),
};
scene.draw_glyphs(&glyph_run, pick_id);
}
}
}
struct DecorationRect {
x0: f32,
x1: f32,
top: f32,
thickness: f32,
}
fn emit_decoration_rect<S: SceneBuilder + ?Sized>(
scene: &mut S,
deco: DecorationRect,
brush: &Brush,
transform: Affine,
pick_id: PickId,
) {
let DecorationRect {
x0,
x1,
top,
thickness,
} = deco;
if !thickness.is_finite() || thickness <= 0.0 || x1 <= x0 {
return;
}
let rect = Rect::new(x0 as f64, top as f64, x1 as f64, (top + thickness) as f64);
let path: crate::path::Path = crate::geometry::Shape::to_path(&rect, 0.1);
scene.fill(
crate::path::FillRule::NonZero,
transform,
brush,
None,
&path,
pick_id,
);
}
pub struct WithMargin {
inner: Box<dyn Measure>,
pub margins_px: (f64, f64, f64, f64),
}
impl WithMargin {
pub fn new(inner: Box<dyn Measure>, margins_px: (f64, f64, f64, f64)) -> Self {
Self { inner, margins_px }
}
}
impl Measure for WithMargin {
fn width_hint(&self, dpi: f64) -> WidthHint {
let (_, r, _, l) = self.margins_px;
match self.inner.width_hint(dpi) {
WidthHint::Min(w) => WidthHint::Min(w + l + r),
WidthHint::NeedsHeight { seed } => WidthHint::NeedsHeight { seed: seed + l + r },
}
}
fn height_at(&self, width: f64, dpi: f64) -> f64 {
let (t, r, b, l) = self.margins_px;
let inner_w = (width - l - r).max(0.0);
self.inner.height_at(inner_w, dpi) + t + b
}
fn width_at(&self, height: f64, dpi: f64) -> f64 {
let (t, r, b, l) = self.margins_px;
let inner_h = (height - t - b).max(0.0);
self.inner.width_at(inner_h, dpi) + l + r
}
}
pub fn draw_text_in_rect<S: SceneBuilder + ?Sized>(
scene: &mut S,
run: &TextRun,
rect: Rect,
brush: &Brush,
pick_id: PickId,
) {
run.set_max_width((rect.x1 - rect.x0) as f32, HAlign::Start);
draw_text(
scene,
run,
rect.x0,
rect.y0,
brush,
Affine::IDENTITY,
pick_id,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_run_has_finite_min_width() {
let style = TextStyle::new(16.0);
let run = TextRun::new("Hello, world!", &style, 96.0);
assert!(
run.min_width.is_finite() && run.min_width > 0.0,
"min width should be positive and finite (got {})",
run.min_width
);
}
#[test]
fn text_run_height_is_font_metric_not_ink() {
let style = TextStyle::new(16.0);
let men = TextRun::new("men", &style, 96.0);
let jay = TextRun::new("jay", &style, 96.0);
assert!(
(men.natural_height() - jay.natural_height()).abs() < 0.01,
"expected font-metric height (descender always reserved): \
men.h={}, jay.h={}",
men.natural_height(),
jay.natural_height()
);
}
#[test]
fn text_run_natural_width_is_positive() {
let style = TextStyle::new(16.0);
let run = TextRun::new("Hello", &style, 96.0);
assert!(
run.natural_width() > 0.0 && run.natural_width().is_finite(),
"natural_width = {}",
run.natural_width()
);
assert!(run.natural_height() > 0.0);
}
#[test]
fn register_font_bytes_returns_zero_for_garbage() {
let n = register_font_bytes(b"not-a-font".to_vec());
assert_eq!(n, 0);
}
#[test]
fn register_font_dir_skips_non_font_files() {
let dir = std::env::temp_dir().join("hephaestus-font-dir-smoke");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("note.txt"), b"hi").expect("write");
std::fs::write(dir.join("garbage.ttf"), b"not-a-font").expect("write");
let result = register_font_dir(&dir).expect("scan");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(result, 0);
}
#[test]
fn draw_text_outline_emits_stroked_glyph_run() {
use crate::brush::Brush;
use crate::color::Color;
use crate::geometry::Affine;
use crate::pick::PickId;
use crate::scene::recording::{Op, RecordingScene};
let brush = Brush::Solid(Color::new([0.0, 0.0, 0.0, 1.0]));
let stroke = crate::stroke::Stroke::new(2.0);
let style = TextStyle::new(16.0);
let run = TextRun::new("Hello", &style, 96.0);
let mut scene = RecordingScene::default();
draw_text_outline(
&mut scene,
&run,
0.0,
0.0,
&brush,
&stroke,
Affine::IDENTITY,
PickId::Skip,
);
let stroked = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawGlyphs(g) if g.style.is_some()))
.count();
assert!(stroked > 0, "expected at least one stroked glyph run");
}
#[test]
fn underline_and_strikethrough_emit_extra_fills() {
use crate::brush::Brush;
use crate::color::Color;
use crate::geometry::Affine;
use crate::pick::PickId;
use crate::scene::recording::{Op, RecordingScene};
let brush = Brush::Solid(Color::new([0.0, 0.0, 0.0, 1.0]));
let plain = TextStyle::new(16.0);
let underlined = TextStyle::new(16.0).underline(true);
let struck = TextStyle::new(16.0).strikethrough(true);
let both = TextStyle::new(16.0).underline(true).strikethrough(true);
let count_fills = |s: &TextStyle| -> usize {
let run = TextRun::new("Hello", s, 96.0);
let mut scene = RecordingScene::default();
draw_text(
&mut scene,
&run,
0.0,
0.0,
&brush,
Affine::IDENTITY,
PickId::Skip,
);
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count()
};
let plain_fills = count_fills(&plain);
assert_eq!(count_fills(&underlined), plain_fills + 1);
assert_eq!(count_fills(&struck), plain_fills + 1);
assert_eq!(count_fills(&both), plain_fills + 2);
}
#[test]
fn letter_spacing_widens_the_layout() {
let base = TextStyle::new(16.0);
let loose = TextStyle::new(16.0).letter_spacing_pt(4.0);
let tight = TextStyle::new(16.0).letter_spacing_pt(-1.0);
let r_base = TextRun::new("Hello", &base, 96.0).natural_width();
let r_loose = TextRun::new("Hello", &loose, 96.0).natural_width();
let r_tight = TextRun::new("Hello", &tight, 96.0).natural_width();
assert!(
r_loose > r_base,
"positive letter spacing should widen: base={r_base}, loose={r_loose}"
);
assert!(
r_tight < r_base,
"negative letter spacing should narrow: base={r_base}, tight={r_tight}"
);
}
#[test]
fn text_run_height_grows_when_wrapped() {
let style = TextStyle::new(16.0);
let run = TextRun::new(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
&style,
96.0,
);
let tall = run.height_at(60.0, 96.0);
let short = run.height_at(2000.0, 96.0);
assert!(
tall > short,
"wrapping at 60px should be taller than at 2000px (got tall={tall}, short={short})"
);
}
#[test]
fn text_run_measure_via_composition_slot() {
use crate::composition::{Patch, Slot};
use crate::layout::Cell;
let style = TextStyle::new(20.0);
let p = Patch::new("p")
.slot(
Slot::AxisLeft,
Cell::measured(TextRun::new("8888", &style, 96.0)),
)
.slot(Slot::Panel, Cell::empty());
let layout = p.solve(crate::geometry::Size::new(400.0, 200.0), 96.0);
let panel = layout.get("p", Slot::Panel).unwrap();
assert!(panel.x0 > 0.0, "axis should consume some width");
assert!(
panel.x1 > panel.x0,
"panel should still have positive width"
);
}
#[test]
fn glyph_marker_returns_finite_em_metrics() {
use crate::shape::ShapeKind;
let style = TextStyle::new(16.0);
let shape = glyph_marker("A", &style);
match shape.kind() {
ShapeKind::Glyph {
glyph_id,
em_bbox,
em_origin,
..
} => {
assert!(glyph_id > 0, "expected a non-zero glyph id for 'A'");
assert!(em_bbox.width() > 0.0 && em_bbox.width().is_finite());
assert!(em_bbox.height() > 0.0 && em_bbox.height().is_finite());
assert!(em_origin.x.is_finite() && em_origin.y.is_finite());
assert!(em_bbox.height() < 2.5, "em height = {}", em_bbox.height());
}
_ => panic!("expected glyph variant"),
}
}
#[test]
fn glyph_marker_anchor_default_is_back_edge() {
let style = TextStyle::new(16.0);
let shape = glyph_marker("A", &style);
let a = shape.anchor();
assert_eq!(a.x, -0.5);
assert_eq!(a.y, 0.0);
}
#[test]
#[should_panic(expected = "shaped to 2 glyphs")]
fn glyph_marker_panics_on_non_ligated_sequence() {
let style = TextStyle::new(16.0);
let _ = glyph_marker("AB", &style);
}
}