use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt::Debug;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use bit_set::BitSet;
use dashmap::{DashMap, Entry};
use itertools::Itertools as _;
use pbf_font_tools::freetype::{Face, Library};
use pbf_font_tools::prost::Message as _;
use pbf_font_tools::{Fontstack, Glyphs, render_sdf_glyph};
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
const MAX_UNICODE_CP: u32 = 0x0010_FFFF;
const CP_RANGE_SIZE: usize = 256;
const FONT_SIZE: usize = 24;
#[expect(clippy::cast_possible_wrap, reason = "FONT_SIZE << 6 is not wrapping")]
const CHAR_HEIGHT: isize = (FONT_SIZE as isize) << 6;
const BUFFER_SIZE: usize = 3;
const RADIUS: usize = 8;
const CUTOFF: f64 = 0.25_f64;
mod error;
pub use error::FontError;
mod cache;
pub use cache::{FontCache, NO_FONT_CACHE, OptFontCache};
type GetGlyphInfo = (BitSet, u32, Vec<(usize, usize)>, usize, usize);
fn get_available_codepoints(face: &mut Face) -> Option<GetGlyphInfo> {
let mut codepoints = BitSet::new();
let mut spans = Vec::new();
let mut first: Option<usize> = None;
let mut last = 0;
for (cp, _) in face.chars() {
codepoints.insert(cp);
if let Some(start) = first {
if cp != last + 1 {
spans.push((start, last));
first = Some(cp);
}
} else {
first = Some(cp);
}
last = cp;
}
if let Some(first) = first {
spans.push((first, last));
let count = u32::try_from(face.num_glyphs()).unwrap_or(0);
let start = spans[0].0;
Some((codepoints, count, spans, start, last))
} else {
None
}
}
pub type FontCatalog = HashMap<String, CatalogFontEntry>;
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CatalogFontEntry {
pub family: String,
pub style: Option<String>,
pub glyphs: u32,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Default)]
pub struct FontSources {
fonts: DashMap<String, FontSource>,
}
impl FontSources {
pub fn recursively_add_directory(&mut self, path: PathBuf) -> Result<(), FontError> {
let lib = Library::init()?;
recurse_dirs(&lib, path, &mut self.fonts, true)
}
#[must_use]
pub fn get_catalog(&self) -> FontCatalog {
self.fonts
.iter()
.map(|v| (v.key().clone(), v.catalog_entry.clone()))
.collect()
}
#[expect(clippy::cast_possible_truncation)]
pub fn get_font_range(&self, ids: &str, start: u32, end: u32) -> Result<Vec<u8>, FontError> {
if start > MAX_UNICODE_CP || end > MAX_UNICODE_CP {
return Err(FontError::InvalidFontRangeStartEnd(start, end));
}
if start > end {
return Err(FontError::InvalidFontRangeStartEnd(start, end));
}
if !start.is_multiple_of(CP_RANGE_SIZE as u32) {
return Err(FontError::InvalidFontRangeStart(start));
}
if end % (CP_RANGE_SIZE as u32) != (CP_RANGE_SIZE as u32 - 1) {
return Err(FontError::InvalidFontRangeEnd(end));
}
if (end - start) != (CP_RANGE_SIZE as u32 - 1) {
return Err(FontError::InvalidFontRange(start, end));
}
let fonts = ids
.split(',')
.map(|id| {
if self.fonts.get(id).is_none() {
return Err(FontError::FontNotFound(id.to_string()));
}
Ok(id)
})
.collect::<Result<Vec<&str>, FontError>>()?;
if fonts.is_empty() {
return Ok(Vec::new());
}
let lib = Library::init()?;
let mut stack = Fontstack::default();
for id in fonts {
let Some(font) = self.fonts.get(id) else {
continue;
};
if stack.name.is_empty() {
stack.name = id.to_string();
} else {
let name = &mut stack.name;
name.push_str(", ");
name.push_str(id);
}
let face = lib.new_face(&font.path, font.face_index)?;
face.set_char_size(0, CHAR_HEIGHT, 0, 0)?;
for codepoint in start..=end {
if !font.codepoints.contains(codepoint as usize) {
continue;
}
let g = render_sdf_glyph(&face, codepoint, BUFFER_SIZE, RADIUS, CUTOFF)?;
stack.glyphs.push(g);
}
}
stack.range = format!("{start}-{end}");
let mut glyphs = Glyphs::default();
glyphs.stacks.push(stack);
Ok(glyphs.encode_to_vec())
}
}
#[derive(Clone, Debug)]
pub struct FontSource {
path: PathBuf,
face_index: isize,
codepoints: Arc<BitSet>,
catalog_entry: CatalogFontEntry,
}
fn recurse_dirs(
lib: &Library,
path: PathBuf,
fonts: &mut DashMap<String, FontSource>,
is_top_level: bool,
) -> Result<(), FontError> {
let start_count = fonts.len();
if path.is_dir() {
for dir_entry in path
.read_dir()
.map_err(|e| FontError::IoError(e, path.clone()))?
.flatten()
{
recurse_dirs(lib, dir_entry.path(), fonts, false)?;
}
if is_top_level && fonts.len() == start_count {
return Err(FontError::NoFontFilesFound(path));
}
} else {
if path
.extension()
.and_then(OsStr::to_str)
.is_some_and(|e| ["otf", "ttf", "ttc"].contains(&e))
{
parse_font(lib, fonts, path.clone())?;
}
if is_top_level && fonts.len() == start_count {
return Err(FontError::InvalidFontFilePath(path));
}
}
Ok(())
}
fn parse_font(
lib: &Library,
fonts: &mut DashMap<String, FontSource>,
path: PathBuf,
) -> Result<(), FontError> {
static RE_SPACES: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\s|/|,)+").expect("regex pattern is valid"));
let mut face = lib.new_face(&path, 0)?;
let num_faces = face.num_faces() as isize;
for face_index in 0..num_faces {
if face_index > 0 {
face = lib.new_face(&path, face_index)?;
}
let Some(family) = face.family_name() else {
return Err(FontError::MissingFamilyName(path));
};
let mut name = family.clone();
let style = face.style_name();
if let Some(style) = &style {
name.push(' ');
name.push_str(style);
}
name = RE_SPACES.replace_all(name.as_str(), " ").to_string();
match fonts.entry(name) {
Entry::Occupied(v) => {
warn!(
"Ignoring duplicate font {} from {} because it was already configured from {}",
v.key(),
path.display(),
v.get().path.display()
);
}
Entry::Vacant(v) => {
let key = v.key();
let Some((codepoints, glyphs, ranges, start, end)) =
get_available_codepoints(&mut face)
else {
warn!(
"Ignoring font {key} from {} because it has no available glyphs",
path.display()
);
continue;
};
info!(
"Configured font {key} with {glyphs} glyphs ({start:04X}-{end:04X}) from {}",
path.display()
);
debug!(
"Available font ranges: {}",
ranges
.iter()
.map(|(s, e)| if s == e {
format!("{s:02X}")
} else {
format!("{s:02X}-{e:02X}")
})
.join(", "),
);
v.insert(FontSource {
path: path.clone(),
face_index,
codepoints: Arc::new(codepoints),
catalog_entry: CatalogFontEntry {
family,
style,
glyphs,
start,
end,
},
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_available_codepoints() {
let lib = Library::init().unwrap();
for codepoint in [0x3320, 0x1f60a] {
let font_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(format!("../tests/fixtures/fonts2/u+{codepoint:x}.ttf"));
assert!(font_path.is_file(), "{}", font_path.display());
let mut face = lib.new_face(&font_path, 0).unwrap();
let (_codepoints, count, _ranges, first, last) =
get_available_codepoints(&mut face).unwrap();
assert_eq!(count, 2);
assert_eq!(format!("U+{first:X}"), format!("U+{codepoint:X}"));
assert_eq!(format!("U+{last:X}"), format!("U+{codepoint:X}"));
}
}
}