Merge branch 'pr/536' into batch1

This commit is contained in:
Brandon Thomas 2026-10-13 02:23:11 -04:00
commit 055da9f788
4 changed files with 323 additions and 0 deletions

View File

@ -121,6 +121,8 @@ pub struct FontFace {
em: std::sync::OnceLock<(f64, f64)>,
/// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
/// Format, class and PANOSE numbers (the font menus), read on first use.
kind: std::sync::OnceLock<crate::kind::FaceKind>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@ -188,6 +190,32 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// The face's format, class and PANOSE numbers, as the font menus show and filter them.
pub fn kind(&self) -> crate::kind::FaceKind {
*self.kind.get_or_init(|| {
let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default();
// Nothing in the tables or the name: look at the letters.
if kind.class == crate::kind::FontClass::Other
&& let Some(serifs) = has_serifs(self)
{
kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif };
}
kind
})
}
/// When the font file was added (created, else last modified): the font menus' Show Recently
/// Added. `None` for fonts without a file.
pub fn added(&self) -> Option<std::time::SystemTime> {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(self.source.path()?).ok()?;
meta.created().or_else(|_| meta.modified()).ok()
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@ -732,6 +760,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
bmp: std::sync::OnceLock::new(),
em: std::sync::OnceLock::new(),
group: std::sync::OnceLock::new(),
kind: std::sync::OnceLock::new(),
})
}
@ -1300,6 +1329,43 @@ impl ScopedFonts<'_> {
None
}
/// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular
/// face (else its first), for the font menus' filters, which look at every family. A face
/// already loaded is asked; an installed family that isn't loaded is read from its file, which
/// is then let go rather than kept loaded. None for an unknown family or an unreadable file.
pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option<std::time::SystemTime>)> {
let styles = self.styles(family);
let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone();
if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) {
let f = self.find(family, &style)?;
return Some((f.kind(), f.added()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = {
let cat = self.db.read_catalog();
let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family);
let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family));
entry.map(|c| c.path.clone())?
};
if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES {
return None;
}
let data = Arc::new(std::fs::read(&path).ok()?);
let found = enumerate_faces(&data);
let pick = found
.iter()
.find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style))
.or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))?
.clone();
let (index, fam, st, coords) = pick;
let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?;
Some((face.kind(), face.added()))
}
#[cfg(target_arch = "wasm32")]
None
}
/// Is `family` available (the document's, loaded, or installed on the system), as named or
/// without a format suffix (see [`ScopedFonts::face`])?
pub fn has_family(&self, family: &str) -> bool {
@ -1455,6 +1521,53 @@ fn over_total() -> String {
format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20)
}
/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when
/// it has neither letter.
fn has_serifs(face: &FontFace) -> Option<bool> {
let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?;
let path = font_outline(face, gid);
let bbox = kurbo::Shape::bounding_box(&path);
// An empty or degenerate outline (NaN included) says nothing.
if bbox.height().is_nan() || bbox.height() <= 1.0 {
return None;
}
let mut edges = Vec::new();
let mut last = None;
let mut start = None;
kurbo::flatten(&path, 1.0, |el| match el {
kurbo::PathEl::MoveTo(p) => {
last = Some(p);
start = Some(p);
}
kurbo::PathEl::LineTo(p) => {
if let Some(a) = last {
edges.push((a, p));
}
last = Some(p);
}
kurbo::PathEl::ClosePath => {
if let (Some(a), Some(b)) = (last, start) {
edges.push((a, b));
}
last = start;
}
_ => {}
});
// The outline is y-down: the foot is near bbox.y1 (the baseline).
let width_at = |y: f64| {
let xs: Vec<f64> = edges
.iter()
.filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y))
.map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y))
.collect();
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
(hi > lo).then_some(hi - lo)
};
let foot = width_at(bbox.y1 - bbox.height() * 0.02)?;
let stem = width_at(bbox.y0 + bbox.height() * 0.5)?;
Some(foot > stem * 1.6)
}
/// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped).
fn font_outline(face: &FontFace, gid: u32) -> BezPath {
let mut pen = FlipPen(BezPath::new());

191
crates/fonts/src/kind.rs Normal file
View File

@ -0,0 +1,191 @@
//! What the font menus say about a face: its file format (the icon after the sample), its
//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts).
use skrifa::raw::TableProvider;
use skrifa::raw::types::Tag;
/// A face's font format, as the font menus show it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontFormat {
/// TrueType outlines (`glyf`).
TrueType,
/// PostScript (CFF) outlines.
OpenType,
/// A variable font (`fvar`).
Variable,
/// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts.
Color,
}
/// The classes the font menus filter by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontClass {
Serif,
SlabSerif,
SansSerif,
Script,
Monospaced,
Decorative,
Symbol,
/// Nothing in the font says.
Other,
}
impl FontClass {
/// The classes, in the Filters menu's order.
pub const ALL: [FontClass; 8] = [
FontClass::Serif,
FontClass::SlabSerif,
FontClass::SansSerif,
FontClass::Script,
FontClass::Monospaced,
FontClass::Decorative,
FontClass::Symbol,
FontClass::Other,
];
/// The name the Filters menu shows (English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
FontClass::Serif => "Serif",
FontClass::SlabSerif => "Slab Serif",
FontClass::SansSerif => "Sans Serif",
FontClass::Script => "Script",
FontClass::Monospaced => "Monospaced",
FontClass::Decorative => "Decorative",
FontClass::Symbol => "Symbol",
FontClass::Other => "Other",
}
}
}
/// A face's format, class and PANOSE numbers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FaceKind {
pub format: FontFormat,
pub class: FontClass,
/// The OS/2 table's PANOSE classification (all zero when the font has none).
pub panose: [u8; 10],
}
impl Default for FaceKind {
fn default() -> Self {
FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] }
}
}
/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say.
pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind {
let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some();
let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) {
FontFormat::Color
} else if has(b"fvar") {
FontFormat::Variable
} else if has(b"CFF ") || has(b"CFF2") {
FontFormat::OpenType
} else {
FontFormat::TrueType
};
let os2 = f.os2().ok();
let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]);
let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8);
let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0);
FaceKind { format, class: classify(&panose, ibm, fixed, family), panose }
}
/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2,
/// else the family name.
pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass {
let name = family.to_lowercase();
let named = |words: &[&str]| words.iter().any(|w| name.contains(w));
// PANOSE proportion 9 is monospaced (Latin text).
if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) {
return FontClass::Monospaced;
}
match panose[0] {
3 => return FontClass::Script,
4 => return FontClass::Decorative,
5 => return FontClass::Symbol,
2 => match panose[1] {
// Square serifs are slabs; cove, bone, triangle… serifs are serifs.
6 => return FontClass::SlabSerif,
2..=10 => return FontClass::Serif,
11..=15 => return FontClass::SansSerif,
_ => {}
},
_ => {}
}
match ibm_class {
1 | 2 | 3 | 7 => FontClass::Serif,
4 | 5 => FontClass::SlabSerif,
8 => FontClass::SansSerif,
9 => FontClass::Decorative,
10 => FontClass::Script,
12 => FontClass::Symbol,
_ if named(&["slab"]) => FontClass::SlabSerif,
_ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif,
_ if named(&["script", "hand", "brush"]) => FontClass::Script,
_ if named(&["serif"]) => FontClass::Serif,
_ => FontClass::Other,
}
}
/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both
/// fonts set; `None` when they share none.
pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option<u32> {
let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect();
if pairs.is_empty() {
return None;
}
Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::<u32>() * 9 / pairs.len() as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classes_come_from_panose_then_the_ibm_class_then_the_name() {
let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0];
assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif);
assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif);
assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif);
assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9");
assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script);
assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8");
assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch");
assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced);
assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif);
assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other);
}
#[test]
fn bundled_fonts_are_classified() {
let db = crate::FontDb::global();
for (family, class) in [
("Source Serif 4", FontClass::Serif),
("Source Sans 3", FontClass::SansSerif),
("Inter", FontClass::SansSerif),
("JetBrains Mono", FontClass::Monospaced),
] {
let kind = db.face(family, "Regular").kind();
assert_eq!(kind.class, class, "{family}");
assert_eq!(kind.format, FontFormat::TrueType, "{family}");
}
let serif = db.face("Source Serif 4", "Regular").kind().panose;
let sans = db.face("Source Sans 3", "Regular").kind().panose;
let serif_bold = db.face("Source Serif 4", "Bold").kind().panose;
if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) {
assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})");
}
}
#[test]
fn panose_distance_counts_shared_digits() {
let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4];
assert_eq!(panose_distance(&a, &a), Some(0));
let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4];
assert!(panose_distance(&a, &b).is_some_and(|d| d > 0));
assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare");
}
}

View File

@ -9,6 +9,7 @@
mod fontdb;
mod group;
mod kind;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod vertical;
@ -20,6 +21,7 @@ pub use fontdb::{
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
pub use harfrust::Feature;
use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer};
pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance};
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;

View File

@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() {
assert_eq!(db.load_system_fonts(), 2);
}
#[test]
fn a_familys_kind_is_read_without_keeping_it_loaded() {
let dir = font_dir("kind");
let db = FontDb::with_font_dirs(vec![dir]);
db.load_system_fonts();
let fonts = db.scoped(0);
let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family");
assert_eq!(kind.class, crate::kind::FontClass::SansSerif);
assert!(added.is_some(), "the file's date");
assert!(!db.is_loaded(FAMILY), "the file was read and let go");
// A loaded family is asked directly, with the same answer.
let _ = fonts.face(FAMILY, "Regular");
assert!(db.is_loaded(FAMILY));
assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif));
assert!(fonts.family_kind("No Such Family").is_none());
}
#[test]
fn the_scan_reads_names_without_loading_the_font() {
let dir = font_dir("names");