From 03a8d4b95525ab14a3ea69f47fd44af65f3cc90f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 1/2] feat(fonts): Font format, classification and PANOSE for the font menus Each face reports what InDesign's font menu shows and filters by, read once from its tables: its format (TrueType, OpenType/CFF, variable, colour), its class (serif, slab serif, sans serif, script, monospaced, decorative, symbol) from PANOSE, else the OS/2 IBM family class, else the family name, else the shape of its "l" (serifs widen the foot), its PANOSE numbers (with a distance for similar fonts) and when its file was added. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 76 +++++++++++++++ crates/fonts/src/kind.rs | 191 +++++++++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 2 + 3 files changed, 269 insertions(+) create mode 100644 crates/fonts/src/kind.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e1..079c0f1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, } /// 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::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 { + #[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::from_index(self.data(), self.index).ok() } @@ -709,6 +737,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(), }) } @@ -1432,6 +1461,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 { + 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 = 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()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 0000000..6a224bc --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -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 { + 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::() * 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"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d7447..dd45837 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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; From 6baa57b503009b10927c563f5d454a1438dfbe06 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:13:57 +0200 Subject: [PATCH 2/2] feat(fonts): A family's kind without keeping it loaded ScopedFonts::family_kind reads a family's format, class, PANOSE and file date from its Regular face. A loaded face is asked; an installed family that isn't loaded is read from its file, which is let go afterwards, instead of loading every family the font menu's filters look at for the rest of the session. Files over MAX_DOCUMENT_FONT_BYTES are skipped. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 37 ++++++++++++++++++++++++++++++ crates/fonts/src/tests_sysfonts.rs | 17 ++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 079c0f1..1ccc8d0 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1306,6 +1306,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)> { + 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 { diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7c..ca82ad4 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -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");