Merge branch 'pr/536' into batch1
This commit is contained in:
commit
055da9f788
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue