use std::path::{Path, PathBuf};
use ab_glyph::{Font, ScaleFont as _};
use crate::map_tiles::MapLike;
#[derive(Debug, thiserror::Error)]
pub enum FontError {
#[error("could not read font file `{path}`: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("could not parse font file as a TrueType/OpenType font: {0}")]
Parse(#[from] ab_glyph::InvalidFont),
}
pub fn load_font(path: &Path) -> Result<ab_glyph::FontVec, FontError> {
let bytes = std::fs::read(path).map_err(|source| FontError::Read {
path: path.to_owned(),
source,
})?;
Ok(ab_glyph::FontVec::try_from_vec(bytes)?)
}
#[must_use]
pub fn display_name_from_file_name(file_name: &str) -> String {
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s);
stem.replace(['-', '_'], " ")
}
#[must_use]
pub fn embedded_font_name(bytes: &[u8]) -> Option<String> {
let face = ttf_parser::Face::parse(bytes, 0).ok()?;
let names = face.names();
for want in [4u16, 16, 1] {
for i in 0..names.len() {
let Some(name) = names.get(i) else { continue };
if name.name_id != want || !name.is_unicode() {
continue;
}
if let Some(decoded) = name.to_string() {
let trimmed = decoded.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
}
}
None
}
#[derive(Debug, Clone, Copy)]
pub struct LabelStyle {
pub scale: ab_glyph::PxScale,
pub fg: image::Rgba<u8>,
pub shadow: image::Rgba<u8>,
pub align: crate::coverage::HAlign,
}
fn line_advance_px<F: Font>(scale: ab_glyph::PxScale, font: &F) -> f32 {
let scaled = font.as_scaled(scale);
scaled.height() + scaled.line_gap()
}
#[must_use]
#[expect(
clippy::module_name_repetitions,
reason = "measure_text reads naturally at call sites; the text module groups text helpers"
)]
pub fn measure_text<F: Font>(scale: ab_glyph::PxScale, font: &F, lines: &[String]) -> (u32, u32) {
if lines.is_empty() {
return (0, 0);
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "font line metrics for a sane pixel size are small positive values, nowhere near u32::MAX"
)]
let line_height = line_advance_px(scale, font).ceil() as u32;
let mut max_w: u32 = 0;
for line in lines {
let (w, _) = imageproc::drawing::text_size(scale, font, line);
if w > max_w {
max_w = w;
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "line counts for any real label are tiny, nowhere near u32::MAX"
)]
let total_h = line_height * lines.len() as u32;
(max_w, total_h)
}
pub(crate) fn draw_text_with_shadow<M, F>(
map: &mut M,
x: i32,
y: i32,
style: &LabelStyle,
font: &F,
text: &str,
) where
M: MapLike + ?Sized,
F: Font,
{
imageproc::drawing::draw_text_mut(
map.image_mut(),
style.shadow,
x + 1,
y + 1,
style.scale,
font,
text,
);
imageproc::drawing::draw_text_mut(map.image_mut(), style.fg, x, y, style.scale, font, text);
}
pub(crate) fn draw_multi_line_with_shadow<M, F>(
map: &mut M,
x: i32,
y: i32,
style: &LabelStyle,
font: &F,
lines: &[String],
) where
M: MapLike + ?Sized,
F: Font,
{
#[expect(
clippy::cast_possible_truncation,
reason = "font line metrics for a sane pixel size are small positive values, nowhere near i32::MAX"
)]
let line_height = line_advance_px(style.scale, font).ceil() as i32;
let mut block_w: u32 = 0;
for line in lines {
let (w, _) = imageproc::drawing::text_size(style.scale, font, line);
if w > block_w {
block_w = w;
}
}
for (i, line) in lines.iter().enumerate() {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "line counts for any real label are tiny, nowhere near i32::MAX"
)]
let line_y = y + line_height * i as i32;
let (line_w, _) = imageproc::drawing::text_size(style.scale, font, line);
#[expect(
clippy::cast_possible_wrap,
reason = "the alignment offset is a small pixel value, nowhere near i32::MAX"
)]
let x_off = style.align.offset(line_w, block_w) as i32;
draw_text_with_shadow(map, x + x_off, line_y, style, font, line);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::map_tiles::Map;
use sl_types::map::{GridCoordinates, GridRectangle, ZoomLevel};
const DEJAVU: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf"));
#[test]
fn display_name_drops_extension_and_replaces_separators() {
assert_eq!(display_name_from_file_name("DejaVuSans.ttf"), "DejaVuSans");
assert_eq!(
display_name_from_file_name("noto-sans-mono.ttf"),
"noto sans mono"
);
assert_eq!(
display_name_from_file_name("source_code_pro.ttf"),
"source code pro"
);
}
#[test]
fn embedded_name_extracts_full_name() {
assert_eq!(embedded_font_name(DEJAVU).as_deref(), Some("DejaVu Sans"));
}
#[test]
fn embedded_name_rejects_garbage() {
assert_eq!(embedded_font_name(b"not a font"), None);
}
#[test]
fn load_font_reads_and_parses() -> Result<(), Box<dyn std::error::Error>> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
load_font(std::path::Path::new(path))?;
assert!(matches!(
load_font(std::path::Path::new("/no/such/font.ttf")),
Err(FontError::Read { .. })
));
Ok(())
}
fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
Ok(ab_glyph::FontVec::try_from_vec(std::fs::read(path)?)?)
}
fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
let grid = GridRectangle::new(
GridCoordinates::new(1130, 1130),
GridCoordinates::new(1140, 1140),
);
Ok(Map::blank(grid, ZoomLevel::try_new(4)?))
}
#[test]
fn measure_text_stacks_lines() -> Result<(), Box<dyn std::error::Error>> {
let font = test_font()?;
let scale = ab_glyph::PxScale::from(16.0);
let one = measure_text(scale, &font, &["Hello".to_owned()]);
let two = measure_text(scale, &font, &["Hello".to_owned(), "Worldly".to_owned()]);
assert!(one.0 > 0 && one.1 > 0);
assert_eq!(two.1, one.1 * 2);
assert!(two.0 >= one.0);
assert_eq!(measure_text(scale, &font, &[]), (0, 0));
Ok(())
}
#[test]
fn draw_text_label_writes_pixels_near_origin() -> Result<(), Box<dyn std::error::Error>> {
let font = test_font()?;
let mut map = blank_map()?;
let style = LabelStyle {
scale: ab_glyph::PxScale::from(20.0),
fg: image::Rgba([255, 255, 255, 255]),
shadow: image::Rgba([0, 0, 0, 200]),
align: crate::coverage::HAlign::Left,
};
map.draw_text_label((40, 40), &["Label".to_owned()], &style, &font);
let mut drawn = false;
for y in 40..80u32 {
for x in 40..200u32 {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
if a != 0 {
drawn = true;
}
}
}
assert!(drawn, "draw_text_label should write visible pixels");
Ok(())
}
#[test]
fn multi_line_aligns_each_line_within_the_block() -> Result<(), Box<dyn std::error::Error>> {
use crate::coverage::HAlign;
let font = test_font()?;
let scale = ab_glyph::PxScale::from(24.0);
let lines = vec!["I".to_owned(), "WWWWWWWW".to_owned()];
let scaled = font.as_scaled(scale);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "test"
)]
let line_h = (scaled.height() + scaled.line_gap()).ceil() as u32;
let first_line_min_x = |map: &Map| -> Option<u32> {
let mut found: Option<u32> = None;
for y in 10..(10 + line_h) {
for x in 0..352u32 {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
if a != 0 {
found = Some(found.map_or(x, |m| m.min(x)));
}
}
}
found
};
let mut left = blank_map()?;
let left_style = LabelStyle {
scale,
fg: image::Rgba([255, 255, 255, 255]),
shadow: image::Rgba([0, 0, 0, 200]),
align: HAlign::Left,
};
left.draw_text_label((10, 10), &lines, &left_style, &font);
let mut right = blank_map()?;
let right_style = LabelStyle {
align: HAlign::Right,
..left_style
};
right.draw_text_label((10, 10), &lines, &right_style, &font);
let left_min = first_line_min_x(&left).ok_or("left first line drew nothing")?;
let right_min = first_line_min_x(&right).ok_or("right first line drew nothing")?;
assert!(
right_min > left_min + 10,
"the short first line should shift right under right alignment (left={left_min}, right={right_min})"
);
Ok(())
}
}