diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8c01c63ff..ca18ba32b 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -50,6 +50,10 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The family name Word lists and stores for the face (name ID 1 style, e.g. "Source Sans 3 + /// Semibold"): the typographic family for Regular, Bold and Italic faces, the family plus the + /// other style words for the rest. See [`legacy_family`]. + pub legacy_family: String, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -158,6 +162,10 @@ impl FontFace { pub fn id(&self) -> u32 { self.id } + /// Is `family` this face's typographic or legacy family name? + pub fn named(&self, family: &str) -> bool { + self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family) + } /// Does the face map `c` to a glyph? pub fn covers(&self, c: char) -> bool { let cp = c as u32; @@ -211,6 +219,7 @@ impl FontFace { struct CatalogEntry { family: String, style: String, + legacy: String, path: std::path::PathBuf, } @@ -318,22 +327,33 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } -/// A face's family and style names (the default instance's style for a variable font). -fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { +/// A face's family and style names (the default instance's style for a variable font), and its +/// legacy family name (name ID 1) when it has one. +fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); - Some((family, style)) + Some((family, style, name(f, &[StringId::FAMILY_NAME]))) } -/// (family, style) of every face in the font file at `path`, reading only its table directories -/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A -/// variable font is cataloged under its default style; its named instances appear once it loads. +/// The family name Word uses for a face that has no legacy name (name ID 1) of its own, such as a +/// variable font's named instance: the family plus the style's words other than Regular, Bold and +/// Italic, which stay styles within it (OpenType `name` table, IDs 1/2 vs 16/17). "Roboto" + +/// "Light Italic" → "Roboto Light"; "Roboto" + "Bold Italic" → "Roboto". +pub(crate) fn legacy_family(family: &str, style: &str) -> String { + const RIBBI: &[&str] = &["regular", "normal", "bold", "italic", "oblique"]; + let extra: Vec<&str> = base_style(style).split_whitespace().filter(|w| !RIBBI.iter().any(|r| w.eq_ignore_ascii_case(r))).collect(); + if extra.is_empty() { family.to_string() } else { format!("{family} {}", extra.join(" ")) } +} + +/// (family, style, legacy family) of every face in the font file at `path`, reading only its +/// table directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of +/// them megabytes long. A variable font yields one entry per named instance, as it does loaded. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { +fn file_face_names(path: &std::path::Path) -> Vec<(String, String, String)> { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; - const MAX_NAME_TABLE: u32 = 1 << 20; + const MAX_TABLE: u32 = 1 << 20; let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; let mut read_at = |offset: u64, len: usize| -> Option> { let mut buf = vec![0; len]; @@ -356,26 +376,51 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { let dir = read_at(start.into(), 12)?; let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; let records = read_at(u64::from(start) + 12, tables * 16)?; - let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; - let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); - if len > MAX_NAME_TABLE { - return None; - } - let table = read_at(offset.into(), len as usize)?; - // A one-table font holding just the `name` table, to read it as the font itself would. - let mut font = Vec::with_capacity(28 + table.len()); - font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); - font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); - font.extend_from_slice(b"name"); - for v in [0, 28, len] { - font.extend_from_slice(&u32::to_be_bytes(v)); - } - font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + let mut table = |tag: &[u8; 4]| -> Option> { + let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > MAX_TABLE { + return None; + } + read_at(offset.into(), len as usize) + }; + let name = table(b"name")?; + // Variable fonts: the named instances. + let mut tables: Vec<([u8; 4], Vec)> = table(b"fvar").map(|t| (*b"fvar", t)).into_iter().collect(); + tables.push((*b"name", name)); + // A font holding just those tables, to read them as the font itself would. + let font = mini_font(&tables); + let faces = font_faces(&skrifa::FontRef::new(&font).ok()?, 0); + Some(faces.into_iter().map(|f| (f.family, f.style, f.legacy)).collect::>()) }) + .flatten() .collect() } +/// A font file holding just `tables` (sorted by tag), each padded to four bytes. +#[cfg(not(target_arch = "wasm32"))] +fn mini_font(tables: &[([u8; 4], Vec)]) -> Vec { + let mut font = Vec::new(); + font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + // numTables, then searchRange, entrySelector and rangeShift (which readers don't need). + font.extend_from_slice(&u16::try_from(tables.len()).unwrap_or(0).to_be_bytes()); + font.extend_from_slice(&[0; 6]); + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in tables { + font.extend_from_slice(tag); + font.extend_from_slice(&[0; 4]); + for v in [offset, data.len()] { + font.extend_from_slice(&u32::try_from(v).unwrap_or(0).to_be_bytes()); + } + offset += data.len().next_multiple_of(4); + } + for (_, data) in tables { + font.extend_from_slice(data); + font.resize(font.len().next_multiple_of(4), 0); + } + font +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. /// Empty on wasm. pub fn system_font_dirs() -> Vec { @@ -414,39 +459,50 @@ pub fn system_font_dirs() -> Vec { dirs } -/// One face found in a font file: index, family, style and (variable fonts) the named instance's -/// axis settings. -type Found = (u32, String, String, Vec<([u8; 4], f32)>); +/// One face found in a font file. +struct Found { + /// Face index within the file. + index: u32, + family: String, + style: String, + /// The legacy family name ([`FontFace::legacy_family`]). + legacy: String, + /// Variable fonts: the named instance's axis settings. + coords: Vec<([u8; 4], f32)>, +} + +/// The faces of `f` (face `index` of its file): one per named instance of a variable font +/// (InDesign lists them as styles), otherwise the font itself. +fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { + let Some((family, style, id1)) = face_names(f) else { return vec![] }; + let axes = f.axes(); + let mut named: Vec = Vec::new(); + for ni in f.named_instances().iter() { + let Some(style) = name(f, &[ni.subfamily_name_id()]) else { continue }; + if named.iter().any(|n| n.style.eq_ignore_ascii_case(&style)) { + continue; + } + let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); + // Name ID 1 names the default instance only. + let legacy = legacy_family(&family, &style); + named.push(Found { index, family: family.clone(), style, legacy, coords }); + } + if !named.is_empty() { + return named; + } + let legacy = id1.filter(|n| !n.eq_ignore_ascii_case(&family)).unwrap_or_else(|| legacy_family(&family, &style)); + vec![Found { index, family, style, legacy, coords: Vec::new() }] +} /// Parse every face in `data` (a font file or collection); a variable font yields one face per -/// named instance (InDesign lists them as styles). +/// named instance. fn enumerate_faces(data: &[u8]) -> Vec { let count = match FileRef::new(data) { Ok(FileRef::Font(_)) => 1, Ok(FileRef::Collection(c)) => c.len(), Err(_) => 0, }; - let mut out = Vec::new(); - for i in 0..count { - let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; - let Some((family, style)) = face_names(&f) else { continue }; - let axes = f.axes(); - let mut named = Vec::new(); - for ni in f.named_instances().iter() { - let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; - if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { - continue; - } - let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); - named.push((i, style, coords)); - } - if named.is_empty() { - out.push((i, family, style, Vec::new())); - } else { - out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); - } - } - out + (0..count).filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i))).flatten().collect() } /// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all. @@ -456,14 +512,14 @@ pub(crate) fn last_resort_face() -> Arc { // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the // `last_resort_face_parses` test proves it on every run. #[allow(clippy::expect_used)] - let face = make_face(FontBytes::Static(BUNDLED[0]), 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) + let face = make_face(FontBytes::Static(BUNDLED[0]), 0, FALLBACK_FAMILY.into(), "Regular".into(), FALLBACK_FAMILY.into(), Vec::new()) .expect("the compiled-in Source Sans 3 Regular parses"); Arc::new(face) }) .clone() } -fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { +fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy_family: String, coords: Vec<([u8; 4], f32)>) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, FontBytes::Owned(v) => v.as_slice(), @@ -487,6 +543,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, + legacy_family, weight, italic, upem: m.units_per_em.max(1) as f64, @@ -547,8 +604,8 @@ impl FontDb { let mut faces = Vec::new(); let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { - for (i, family, style, coords) in enumerate_faces(data) { - if let Some(f) = make_face(FontBytes::Static(data), i, family, style, coords) { + for f in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { faces.push(Arc::new(f)); } } @@ -601,28 +658,38 @@ impl FontDb { self.catalog.read().unwrap_or_else(|e| e.into_inner()) } - /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. + /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. Like + /// Word's font list, this holds each face's legacy family name too ("Roboto Light", "Inter + /// SemiBold"), which [`FontDb::face`] resolves to that face. pub fn families(&self) -> Vec { - let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + let mut v: Vec = self.read_faces().iter().flat_map(|f| [f.family.clone(), f.legacy_family.clone()]).collect(); #[cfg(not(target_arch = "wasm32"))] - v.extend(self.read_catalog().iter().map(|c| c.family.clone())); + v.extend(self.read_catalog().iter().flat_map(|c| [c.family.clone(), c.legacy.clone()])); v.sort_by_key(|a| a.to_lowercase()); v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); v } - /// Style names available for `family` (Regular first, then by weight). + /// Style names available for `family` (Regular first, then by weight). For a legacy family + /// name ("Roboto Light"), the styles of the faces it names ("Light", "Light Italic"). pub fn styles(&self, family: &str) -> Vec { - let mut v: Vec<(bool, f32, String)> = self - .read_faces() - .iter() - .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) - .map(|f| (f.italic, f.weight, f.style.clone())) - .collect(); - #[cfg(not(target_arch = "wasm32"))] - for c in self.read_catalog().iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + let mut v: Vec<(bool, f32, String)> = Vec::new(); + for legacy in [false, true] { + let named = |typographic: &str, legacy_name: &str| (if legacy { legacy_name } else { typographic }).eq_ignore_ascii_case(family); + v.extend( + self.read_faces() + .iter() + .filter(|f| named(&f.family, &f.legacy_family) && !f.style.contains('{')) + .map(|f| (f.italic, f.weight, f.style.clone())), + ); + #[cfg(not(target_arch = "wasm32"))] + for c in self.read_catalog().iter() { + if named(&c.family, &c.legacy) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + if !v.is_empty() { + break; } } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); @@ -644,8 +711,8 @@ impl FontDb { let mut new = Vec::new(); for bytes in files { let data = Arc::new(bytes); - for (i, family, style, coords) in enumerate_faces(&data) { - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + for f in enumerate_faces(&data) { + if let Some(f) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) { new.push(Arc::new(f)); } } @@ -713,8 +780,8 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style) in file_face_names(&p) { - found.push(CatalogEntry { family, style, path: p.clone() }); + for (family, style, legacy) in file_face_names(&p) { + found.push(CatalogEntry { family, style, legacy, path: p.clone() }); } } } @@ -724,7 +791,9 @@ impl FontDb { n } - /// Load the files of the installed `family`. Returns whether any face was added. + /// Load the files of the installed `family`: the whole typographic family, also when asked for + /// by a legacy name ("Roboto Light" loads Roboto), so a family never loads with only some of + /// its styles. Returns whether any face was added. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { let _loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); @@ -734,7 +803,12 @@ impl FontDb { } let paths: Vec = { let cat = self.read_catalog(); - let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + let families: Vec<&str> = cat + .iter() + .filter(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) + .map(|c| c.family.as_str()) + .collect(); + let mut p: Vec<_> = cat.iter().filter(|c| families.iter().any(|f| c.family.eq_ignore_ascii_case(f))).map(|c| c.path.clone()).collect(); p.sort(); p.dedup(); p @@ -744,7 +818,9 @@ impl FontDb { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. + /// Source Sans 3 Regular. `family` is a typographic family ("Roboto") or a face's legacy family + /// name ("Roboto Light", whose styles are Light and Light Italic). Installed system fonts are + /// found by name whatever ran before. pub fn face(&self, family: &str, style: &str) -> Arc { if let Some(f) = self.find(family, style) { return f; @@ -769,14 +845,15 @@ impl FontDb { return true; } #[cfg(not(target_arch = "wasm32"))] - if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) { return true; } false } + /// Is a face named `family` (its typographic or legacy family) loaded? fn is_loaded(&self, family: &str) -> bool { - self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + self.read_faces().iter().any(|f| f.named(family)) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -814,7 +891,7 @@ impl FontDb { None => coords.push((tag, v)), } } - let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?; let f = Arc::new(f); self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); Some(f) @@ -824,14 +901,18 @@ impl FontDb { if style.contains('{') { { let faces = self.read_faces(); - if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) { + if let Some(f) = faces.iter().find(|f| f.named(family) && f.style == style) { return Some(f.clone()); } } return self.instance(family, style); } let faces = self.read_faces(); - let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + let mut cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + // A legacy family name: choose among the faces it names (bold and italic as usual). + cands = faces.iter().filter(|f| f.legacy_family.eq_ignore_ascii_case(family)).collect(); + } if cands.is_empty() { return None; } @@ -954,8 +1035,7 @@ impl FontDb { continue; } let Ok(data) = std::fs::read(&p) else { continue }; - let hit = - enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + let hit = enumerate_faces(&data).iter().any(|f| skrifa::FontRef::from_index(&data, f.index).is_ok_and(|f| f.charmap().map(c).is_some())); if hit && self.add_font(data) > 0 && covered(self) { return true; } @@ -1010,3 +1090,62 @@ impl OutlinePen for FlipPen { #[cfg(not(target_arch = "wasm32"))] #[path = "tests_sysfonts.rs"] mod tests_sysfonts; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_family_keeps_the_style_words_that_are_not_regular_bold_or_italic() { + for (family, style, legacy) in [ + ("Roboto", "Regular", "Roboto"), + ("Roboto", "Bold Italic", "Roboto"), + ("Roboto", "italic", "Roboto"), + ("Roboto", "Light", "Roboto Light"), + ("Roboto", "Light Italic", "Roboto Light"), + ("Roboto", "Condensed Bold", "Roboto Condensed"), + ("Inter", "SemiBold", "Inter SemiBold"), + ("Inter", "ExtraBold Italic", "Inter ExtraBold"), + ("Inter", "Medium {wght:520}", "Inter Medium"), + ("Inter", "", "Inter"), + ] { + assert_eq!(legacy_family(family, style), legacy, "{family} {style}"); + } + } + + #[test] + fn faces_are_found_by_their_legacy_family_name() { + // Bundled static fonts whose name ID 1 differs from the typographic family (#219). + let db = FontDb::with_font_dirs(vec![]); + let families = db.families(); + for name in ["Inter", "Inter Medium", "Inter SemiBold", "Source Sans 3 Semibold", "Source Serif 4 Semibold"] { + assert!(families.iter().any(|f| f == name), "{name} in {families:?}"); + assert!(db.has_family(name)); + } + let semibold = db.face("Source Serif 4 Semibold", "Regular"); + assert_eq!((semibold.family.as_str(), semibold.style.as_str()), ("Source Serif 4", "Semibold")); + assert_eq!(db.face("inter medium", "Regular").style, "Medium"); + assert_eq!(db.face("Inter SemiBold", "Regular").style, "SemiBold"); + // Bold and italic choose within the faces the legacy name covers; the family's own Bold + // isn't one of them. + assert_eq!(db.face("Source Serif 4 Semibold", "Bold").style, "Semibold"); + assert_eq!(db.face("Source Serif 4 Semibold", "Italic").style, "Semibold"); + assert_eq!(db.styles("Source Sans 3 Semibold"), ["Semibold"]); + // The typographic family is unchanged. + assert_eq!(db.face("Source Serif 4", "Bold").style, "Bold"); + assert_eq!(db.face("Source Serif 4", "Regular").style, "Regular"); + assert_eq!(db.face("Source Serif 4", "Regular").legacy_family, "Source Serif 4"); + // Not a name any face has. + assert!(!db.has_family("Source Serif 4 Light")); + assert_eq!(db.face("Source Serif 4 Light", "Regular").family, FALLBACK_FAMILY); + } + + #[test] + fn a_legacy_named_instance_with_axis_settings_is_made_once() { + let db = FontDb::with_font_dirs(vec![]); + let a = db.face("Inter Medium", "Medium {wght:520}"); + let b = db.face("Inter Medium", "Medium {wght:520}"); + assert_eq!(a.id(), b.id()); + assert_eq!(a.legacy_family, "Inter Medium"); + } +} diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 11cb12e5d..573ac2ef8 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -171,8 +171,54 @@ fn fallback_lookups_are_remembered_until_fonts_load() { #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); - assert_eq!(file_face_names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string())]); + assert_eq!(file_face_names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string(), FAMILY.to_string())]); assert!(file_face_names(&dir.join("damaged.ttf")).is_empty()); assert!(file_face_names(&dir.join("readme.txt")).is_empty()); assert!(file_face_names(&dir.join("missing.ttf")).is_empty()); } + +#[test] +fn installed_weights_are_found_by_their_legacy_family_name() { + // Semibold is not one of Regular, Bold and Italic: the font names it "Sysfont Sans3 Semibold" + // (name ID 1), the way Word lists and stores it (#219). + let dir = font_dir("legacy"); + std::fs::write(dir.join("Sysfont-Semibold.ttf"), renamed("SourceSans3-Semibold.ttf")).unwrap(); + let semibold = format!("{FAMILY} Semibold"); + assert_eq!(file_face_names(&dir.join("Sysfont-Semibold.ttf")), [(FAMILY.to_string(), "Semibold".to_string(), semibold.clone())]); + let db = || FontDb::with_font_dirs(vec![dir.clone()]); + assert!(has(&db().families(), FAMILY) && has(&db().families(), &semibold)); + assert!(db().has_family(&semibold.to_uppercase())); + assert_eq!(db().styles(&semibold), ["Semibold"]); + assert_eq!(db().styles(FAMILY), ["Regular", "Semibold", "Bold"]); + // The first lookup, by the legacy name, finds the face and loads the whole family with it. + let db = db(); + let f = db.face(&semibold, "Regular"); + assert_eq!((f.family.as_str(), f.style.as_str(), f.legacy_family.as_str()), (FAMILY, "Semibold", semibold.as_str())); + assert_eq!(db.face(FAMILY, "Regular").style, "Regular"); + assert_eq!(db.face(FAMILY, "Bold").style, "Bold"); +} + +#[test] +fn the_scan_lists_a_variable_fonts_named_instances_as_loading_it_does() { + // Uses an installed variable font when there is one. + let Some(path) = [ + "/System/Library/Fonts/Supplemental/Skia.ttf", + "/System/Library/Fonts/NewYork.ttf", + "/usr/share/fonts/cantarell/Cantarell-VF.otf", + "/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf", + "C:\\Windows\\Fonts\\bahnschrift.ttf", + ] + .iter() + .map(Path::new) + .find(|p| p.exists()) else { + eprintln!("no variable font installed: nothing to check"); + return; + }; + let data = std::fs::read(path).unwrap(); + let loaded: Vec<(String, String, String)> = enumerate_faces(&data).into_iter().map(|f| (f.family, f.style, f.legacy)).collect(); + assert_eq!(file_face_names(path), loaded); + if loaded.len() > 1 { + // Instances other than Regular, Bold and Italic have names of their own. + assert!(loaded.iter().any(|(family, _, legacy)| family != legacy), "{loaded:?}"); + } +} diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5357d10da..b4461c0f8 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -263,6 +263,18 @@ mod tests { assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None); } + #[test] + fn resolves_weights_named_as_their_own_family() { + // Word stores a Medium face as the family "Inter Medium" (#219). + let r = resolve("Inter Medium", false, false); + assert!(!r.substituted && !r.synth_bold && !r.synth_italic); + assert_eq!((r.face.family.as_str(), r.face.style.as_str()), ("Inter", "Medium")); + // Bold and italic within it are drawn from the Medium face. + let b = resolve("Inter Medium", true, true); + assert_eq!(b.face.style, "Medium"); + assert!(!b.substituted && b.synth_bold && b.synth_italic); + } + #[test] fn synthesizes_missing_styles() { // JetBrains Mono ships only Regular here.