From f4a694ecf0c294ee91e9158c532eade2f86d37e4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:02:40 +0200 Subject: [PATCH] =?UTF-8?q?Font=20list=20offers=20every=20installed=20weig?= =?UTF-8?q?ht=20(Light,=20Medium,=20SemiBold,=20Black=E2=80=A6),=20as=20Wo?= =?UTF-8?q?rd=20does=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tests: shapes and pictures can be clicked, resized, dragged and nudged Fixed by #46 but only text boxes were covered. These walk the editor's path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test, select, resize with arrange.bounds, drag to float with Square wrap, nudge, undo. Covers #142 and #82. Co-Authored-By: Claude Opus 5.5 * Font list offers every weight a family installs, named as Word names them (#219) Fonts were listed and matched only by their typographic family name (name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic (Light, Medium, SemiBold, Black, Condensed...) never appeared in the font list, and a document asking for "Roboto Light" fell back to a substitute. Each face now also carries its legacy family name, the one Word lists and stores in documents: name ID 1 for static fonts ("Source Sans 3 Semibold"), or the family plus the style words other than Regular, Bold and Italic for a variable font's named instances ("Inter Light"). The font list includes these names, has_family and face() resolve them (bold and italic still choose or synthesise within the faces the name covers), and asking for a legacy name loads the whole installed family. The system font scan now also reads fvar, so a variable font's instances are listed before it is loaded. Co-Authored-By: Claude Opus 5.5 * Absolutely positioned VML text boxes float instead of sitting inline (#149) A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its style was read as an inline object, so a tall text box in a header made the header as tall as the box and pushed the body down the page. The reader now maps the VML style to the same float model DrawingML anchors use: left/margin-left and top/margin-top offsets, mso-position-*-relative reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-* and w10:wrap (square, tight, through, topAndBottom; none or absent is in front of the text, or behind it at a negative z-index). Shapes without position:absolute stay inline. Saving writes the boxes as DrawingML anchors with the same placement. Co-Authored-By: Claude Opus 5.5 * Add a System interface theme that follows the OS appearance (#115) The interface theme is now a three-way choice: Use system setting, Light or Dark, in File > Options > General and in a new Interface Theme menu in View > Dark Mode. System follows the light/dark appearance egui reports (winit on macOS, Windows and Linux; prefers-color-scheme on the web) at launch and live, and falls back to light when the OS reports none. egui's own theme is now pinned to ours, so an OS change no longer swaps in egui's unstyled style. Saved preferences migrate: the old "dark" switch becomes Dark or Light and is never written again. Light stays the default for new installs. New ui.theme command (light/dark/system); ui.dark still sets Light/Dark. The dark page (View > Switch Modes) is unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/fonts/src/fontdb.rs | 301 +++++++++++++++++++++-------- crates/fonts/src/tests_sysfonts.rs | 48 ++++- crates/fonts/src/word.rs | 12 ++ 3 files changed, 279 insertions(+), 82 deletions(-) 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.