use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use ab_glyph::{Font, FontVec, GlyphId, PxScale, ScaleFont, point};
use eframe::egui::{Pos2, pos2};
use crate::raster::{IRect, Mask};
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub family: usize,
pub size_pt: f32,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub opaque: bool,
}
impl TextStyle {
pub fn px(&self) -> f32 {
self.size_pt * 96.0 / 72.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct Face {
path: PathBuf,
index: u32,
}
pub struct FontFamily {
pub name: String,
regular: Option<Face>,
bold: Option<Face>,
italic: Option<Face>,
bold_italic: Option<Face>,
}
#[derive(Clone)]
pub struct LoadedFont {
font: Rc<FontVec>,
fake_bold: bool,
fake_italic: bool,
}
pub struct FontLibrary {
pub families: Vec<FontFamily>,
cache: HashMap<Face, Option<Rc<FontVec>>>,
}
fn is_font_file(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
matches!(ext.as_str(), "ttf" | "otf" | "ttc")
}
fn add_face(by_name: &mut HashMap<String, FontFamily>, name: &str, bold: bool, italic: bool, face: Face) {
let fam = by_name.entry(name.to_string()).or_insert_with(|| FontFamily {
name: name.to_string(),
regular: None,
bold: None,
italic: None,
bold_italic: None,
});
let slot = match (bold, italic) {
(false, false) => &mut fam.regular,
(true, false) => &mut fam.bold,
(false, true) => &mut fam.italic,
(true, true) => &mut fam.bold_italic,
};
slot.get_or_insert(face);
}
fn scan_dirs(dirs: &[PathBuf], recursive: bool, by_name: &mut HashMap<String, FontFamily>) {
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if recursive && path.is_dir() {
scan_dirs(&[path], true, by_name);
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
if !is_font_file(&path) || stem.starts_with('.') {
continue;
}
let (name, bold, italic) = split_style(stem);
add_face(by_name, name, bold, italic, Face { path: path.clone(), index: 0 });
}
}
}
#[cfg(windows)]
fn scan_named(dirs: &[PathBuf], by_name: &mut HashMap<String, FontFamily>) {
use ttf_parser::{Face as TtfFace, Language, name_id};
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if !is_font_file(&path) {
continue;
}
let Ok(data) = std::fs::read(&path) else { continue };
for index in 0..ttf_parser::fonts_in_collection(&data).unwrap_or(1) {
let Ok(face) = TtfFace::parse(&data, index) else { continue };
let names: Vec<_> = face.names().into_iter().filter(|n| n.name_id == name_id::FAMILY && n.is_unicode()).collect();
let family = names
.iter()
.find(|n| n.language() == Language::English_UnitedStates)
.or(names.first())
.and_then(|n| n.to_string());
if let Some(family) = family.filter(|f| !f.is_empty()) {
add_face(by_name, &family, face.is_bold(), face.is_italic(), Face { path: path.clone(), index });
}
}
}
}
}
fn scan_fontconfig(by_name: &mut HashMap<String, FontFamily>) -> bool {
let Ok(out) = std::process::Command::new("fc-list").args(["--format", "%{file}\t%{index}\t%{family[0]}\t%{style[0]}\n"]).output() else {
return false;
};
if !out.status.success() {
return false;
}
let mut fallback: Vec<(String, Face)> = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let f: Vec<&str> = line.split('\t').collect();
let [file, index, family, style] = f[..] else { continue };
let path = PathBuf::from(file);
if !is_font_file(&path) || family.is_empty() {
continue;
}
let face = Face { path, index: index.parse().unwrap_or(0) };
let style = style.to_ascii_lowercase();
match style.as_str() {
"regular" | "book" | "normal" | "roman" => add_face(by_name, family, false, false, face),
"bold" => add_face(by_name, family, true, false, face),
"italic" | "oblique" => add_face(by_name, family, false, true, face),
"bold italic" | "bold oblique" => add_face(by_name, family, true, true, face),
_ => fallback.push((family.to_string(), face)),
}
}
for (family, face) in fallback {
if by_name.get(&family).is_none_or(|f| f.regular.is_none()) {
add_face(by_name, &family, false, false, face);
}
}
!by_name.is_empty()
}
pub fn fc_match(pattern: &str) -> Option<(PathBuf, u32)> {
let out = std::process::Command::new("fc-match").args(["--format", "%{file}\t%{index}", pattern]).output().ok()?;
let text = String::from_utf8(out.stdout).ok()?;
let (file, index) = text.split_once('\t')?;
let path = PathBuf::from(file);
is_font_file(&path).then(|| (path, index.trim().parse().unwrap_or(0)))
}
impl FontLibrary {
pub fn scan() -> Self {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut by_name: HashMap<String, FontFamily> = HashMap::new();
#[cfg(windows)]
{
let windir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
let mut dirs = vec![windir.join("Fonts")];
dirs.extend(std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join(r"Microsoft\Windows\Fonts")));
scan_named(&dirs, &mut by_name);
}
if cfg!(windows) {
} else if cfg!(target_os = "macos") {
let mut dirs = vec![
PathBuf::from("/System/Library/Fonts/Supplemental"),
PathBuf::from("/System/Library/Fonts"),
PathBuf::from("/Library/Fonts"),
];
dirs.extend(home.map(|h| h.join("Library/Fonts")));
scan_dirs(&dirs, false, &mut by_name);
} else if !scan_fontconfig(&mut by_name) {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
dirs.extend(home.iter().flat_map(|h| [h.join(".local/share/fonts"), h.join(".fonts")]));
scan_dirs(&dirs, true, &mut by_name);
}
let mut families: Vec<FontFamily> = by_name.into_values().filter(|f| f.regular.is_some()).collect();
families.sort_by_key(|f| f.name.to_lowercase());
Self { families, cache: HashMap::new() }
}
pub fn default_family(&self) -> usize {
["Calibri", "Arial", "Helvetica", "Verdana", "Liberation Sans", "DejaVu Sans", "Noto Sans", "Cantarell", "Ubuntu"]
.iter()
.find_map(|want| self.families.iter().position(|f| f.name == *want))
.unwrap_or(0)
}
pub fn load(&mut self, family: usize, bold: bool, italic: bool) -> Option<LoadedFont> {
let fam = self.families.get(family)?;
let exact = match (bold, italic) {
(false, false) => fam.regular.clone(),
(true, false) => fam.bold.clone(),
(false, true) => fam.italic.clone(),
(true, true) => fam.bold_italic.clone(),
};
let (face, fake_bold, fake_italic) = match exact {
Some(f) => (f, false, false),
None if bold && italic && fam.bold.is_some() => (fam.bold.clone()?, false, true),
None if bold && italic && fam.italic.is_some() => (fam.italic.clone()?, true, false),
None => (fam.regular.clone()?, bold, italic),
};
let font = self
.cache
.entry(face.clone())
.or_insert_with(|| {
let data = std::fs::read(&face.path).ok()?;
FontVec::try_from_vec_and_index(data, face.index).ok().map(Rc::new)
})
.clone()?;
Some(LoadedFont { font, fake_bold, fake_italic })
}
}
fn split_style(stem: &str) -> (&str, bool, bool) {
for (suffix, b, i) in [
(" Bold Italic", true, true),
(" BoldItalic", true, true),
(" Bold Oblique", true, true),
(" Bold", true, false),
(" Italic", false, true),
(" Oblique", false, true),
("-BoldItalic", true, true),
("-BoldOblique", true, true),
("-Bold", true, false),
("-Italic", false, true),
("-Oblique", false, true),
("-Regular", false, false),
] {
if let Some(name) = stem.strip_suffix(suffix) {
return (name, b, i);
}
}
(stem, false, false)
}
pub struct TextLayout {
pub width: f32,
pub height: f32,
}
const ITALIC_SHEAR: f32 = 0.2;
pub fn measure(font: &LoadedFont, px: f32, text: &str) -> TextLayout {
let scaled = font.font.as_scaled(PxScale::from(px));
let line_h = scaled.ascent() - scaled.descent() + scaled.line_gap();
let mut width: f32 = 0.0;
let mut lines = 0;
for line in text.split('\n') {
lines += 1;
width = width.max(line_width(&scaled, line));
}
if font.fake_italic {
width += px * ITALIC_SHEAR;
}
if font.fake_bold {
width += bold_offset(px) as f32;
}
TextLayout { width, height: lines as f32 * line_h }
}
fn line_width<F: Font>(scaled: &impl ScaleFont<F>, line: &str) -> f32 {
let mut x = 0.0;
let mut prev: Option<GlyphId> = None;
for ch in line.chars() {
let id = scaled.glyph_id(ch);
if let Some(p) = prev {
x += scaled.kern(p, id);
}
x += scaled.h_advance(id);
prev = Some(id);
}
x
}
fn bold_offset(px: f32) -> i32 {
(px / 28.0).ceil().max(1.0) as i32
}
pub fn render(mask: &mut Mask, font: &LoadedFont, px: f32, text: &str, origin: Pos2, underline: bool, strike: bool) -> IRect {
let scaled = font.font.as_scaled(PxScale::from(px));
let ascent = scaled.ascent();
let line_h = ascent - scaled.descent() + scaled.line_gap();
let bold = if font.fake_bold { bold_offset(px) } else { 0 };
let mut out = IRect::EMPTY;
for (li, line) in text.split('\n').enumerate() {
let baseline = origin.y + ascent + li as f32 * line_h;
let mut x = origin.x;
let mut prev: Option<GlyphId> = None;
for ch in line.chars() {
let id = scaled.glyph_id(ch);
if let Some(p) = prev {
x += scaled.kern(p, id);
}
let glyph = id.with_scale_and_position(px, point(x, baseline));
x += scaled.h_advance(id);
prev = Some(id);
let Some(outlined) = font.font.outline_glyph(glyph) else { continue };
let b = outlined.px_bounds();
let (bx, by) = (b.min.x as i32, b.min.y as i32);
let shear_of = |py: i32| {
if font.fake_italic { ((baseline - py as f32) * ITALIC_SHEAR).round() as i32 } else { 0 }
};
outlined.draw(|gx, gy, c| {
let py = by + gy as i32;
let px_ = bx + gx as i32 + shear_of(py);
for k in 0..=bold {
mask.max_at(px_ + k, py, c);
}
});
let top = shear_of(by);
let bottom = shear_of(b.max.y as i32);
out = out.union(IRect::new(
bx + top.min(bottom) - 1,
by - 1,
b.max.x.ceil() as i32 + top.max(bottom) + bold + 1,
b.max.y.ceil() as i32 + 1,
));
}
let thick = (px / 16.0).max(1.0);
let mut rule = |y: f32| mask.fill_polygon(&[pos2(origin.x, y), pos2(x, y), pos2(x, y + thick), pos2(origin.x, y + thick)], 1.0);
if underline && x > origin.x {
out = out.union(rule(baseline + (px * 0.1).max(1.0)));
}
if strike && x > origin.x {
out = out.union(rule(baseline - px * 0.3));
}
}
out.intersect(mask.bounds())
}
#[derive(Clone, Debug)]
pub struct CaretLayout {
pub lines: Vec<Vec<f32>>,
pub line_h: f32,
}
impl Default for CaretLayout {
fn default() -> Self {
Self { lines: vec![vec![0.0]], line_h: 16.0 }
}
}
impl CaretLayout {
pub fn line_col(&self, index: usize) -> (usize, usize) {
let mut i = index;
for (l, xs) in self.lines.iter().enumerate() {
let len = xs.len() - 1;
if i <= len {
return (l, i);
}
i -= len + 1;
}
let last = self.lines.len() - 1;
(last, self.lines[last].len() - 1)
}
pub fn index(&self, line: usize, col: usize) -> usize {
self.lines[..line].iter().map(|xs| xs.len()).sum::<usize>() + col
}
pub fn caret(&self, index: usize) -> eframe::egui::Vec2 {
let (l, c) = self.line_col(index);
eframe::egui::vec2(self.lines[l][c], l as f32 * self.line_h)
}
pub fn col_at(&self, line: usize, x: f32) -> usize {
let xs = &self.lines[line];
(0..xs.len()).min_by(|&a, &b| (xs[a] - x).abs().total_cmp(&(xs[b] - x).abs())).unwrap_or(0)
}
pub fn index_at(&self, p: eframe::egui::Vec2) -> usize {
let line = ((p.y / self.line_h).floor().max(0.0) as usize).min(self.lines.len() - 1);
self.index(line, self.col_at(line, p.x))
}
}
pub fn caret_layout(font: &LoadedFont, px: f32, text: &str) -> CaretLayout {
let scaled = font.font.as_scaled(PxScale::from(px));
let line_h = scaled.ascent() - scaled.descent() + scaled.line_gap();
let lines = text
.split('\n')
.map(|line| {
let mut xs = vec![0.0];
let mut x = 0.0;
let mut prev: Option<GlyphId> = None;
for ch in line.chars() {
let id = scaled.glyph_id(ch);
if let Some(p) = prev {
x += scaled.kern(p, id);
}
x += scaled.h_advance(id);
xs.push(x);
prev = Some(id);
}
xs
})
.collect();
CaretLayout { lines, line_h }
}