Merge pull request #170 from storytold/fix/issue-130
Fix #130: installed fonts are found by name whatever ran before
This commit is contained in:
commit
2d740acbd1
|
|
@ -24,6 +24,8 @@ mod text;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fontnames;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
//! Type names the available family as the font spells it, not as the PostScript name splits into
|
||||
//! words (#130).
|
||||
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use crate::import::import;
|
||||
|
||||
/// The bundled Source Sans 3 renamed `family` (as long as the original name).
|
||||
fn renamed(family: &str) -> Vec<u8> {
|
||||
let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
assert_eq!(from.len(), to.len());
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postscript_names_resolve_to_the_available_family() {
|
||||
// Split into words, its PostScript name would read "My Face Ya Hei3".
|
||||
assert!(FontDb::global().add_font(renamed("MyFaceYaHei 3")) > 0);
|
||||
let body = "/MyFaceYaHei3-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show";
|
||||
let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n");
|
||||
let r = import(eps.as_bytes()).unwrap();
|
||||
let mut styles = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
styles.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
|
||||
}
|
||||
});
|
||||
assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]);
|
||||
}
|
||||
|
|
@ -223,7 +223,9 @@ impl Interp<'_> {
|
|||
if !(size.is_finite() && size > 1e-3 && size < 1e5) {
|
||||
return ps_err("undefinedresult", "a font size");
|
||||
}
|
||||
let (font_family, font_style) = family_style(&name);
|
||||
let (family, font_style) = family_style(&name);
|
||||
// The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
|
||||
let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
|
||||
let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect();
|
||||
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
|
||||
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
//! PDF import names type by the installed fonts in a new session (#130). One test in its own
|
||||
//! process, so the process-wide font database starts fresh, as it does when the app starts.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_pdf::{ImportOptions, import_with_report};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf_with};
|
||||
use vectorcraft_text::{FontDb, system_font_dirs};
|
||||
|
||||
#[test]
|
||||
fn a_fresh_session_resolves_pdf_fonts_to_installed_families() {
|
||||
let bundled = FontDb::with_font_dirs(vec![]).families();
|
||||
let probe = FontDb::with_font_dirs(system_font_dirs());
|
||||
probe.load_system_fonts();
|
||||
let installed = probe.families();
|
||||
// A family whose PDF name is its name without the spaces (as "TimesNewRoman").
|
||||
let Some(family) = installed.iter().find(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' '))
|
||||
else {
|
||||
eprintln!("no system fonts installed: nothing to check");
|
||||
return;
|
||||
};
|
||||
let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', ""));
|
||||
let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") };
|
||||
let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap();
|
||||
let mut families = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
families.extend(t.runs.iter().map(|r| r.style.font_family.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(families, std::slice::from_ref(family));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache.
|
||||
//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged once, loaded
|
||||
//! on demand), outline cache.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
|
|
@ -139,21 +141,34 @@ impl FontFace {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogEntry {
|
||||
family: String,
|
||||
style: String,
|
||||
path: std::path::PathBuf,
|
||||
/// One installed family found by the system font scan: its name and the file of each style.
|
||||
#[derive(Debug, Default)]
|
||||
struct CatalogFamily {
|
||||
name: String,
|
||||
/// (style, file) of each face.
|
||||
faces: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as
|
||||
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
|
||||
type Catalog = HashMap<String, CatalogFamily>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<PathBuf>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// System fallback state: scanned yet, and characters no system font covers.
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
||||
family_cache: Mutex<Option<Arc<[String]>>>,
|
||||
generation: AtomicU64,
|
||||
/// System fallback state: characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
}
|
||||
|
|
@ -162,7 +177,6 @@ pub struct FontDb {
|
|||
#[derive(Default)]
|
||||
struct SysFallback {
|
||||
enabled: bool,
|
||||
scanned: bool,
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +217,13 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
|||
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, style) names.
|
||||
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
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))
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
let count = match FileRef::new(data) {
|
||||
|
|
@ -212,14 +233,90 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
|||
};
|
||||
(0..count)
|
||||
.filter_map(|i| {
|
||||
let f = skrifa::FontRef::from_index(data, i).ok()?;
|
||||
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());
|
||||
let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?;
|
||||
Some((i, family, style))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (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.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_face_names(path: &Path) -> Vec<(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;
|
||||
let Ok(mut file) = std::fs::File::open(path) else { return vec![] };
|
||||
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0; len];
|
||||
file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
file.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
||||
let Some(head) = read_at(0, 12) else { return vec![] };
|
||||
// A collection lists where each face's table directory starts.
|
||||
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
||||
let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize;
|
||||
read_at(12, n * 4).map(|b| b.chunks_exact(4).filter_map(|c| be32(c, 0)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
vec![0]
|
||||
};
|
||||
starts
|
||||
.into_iter()
|
||||
.filter_map(|start| {
|
||||
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.chunks_exact(16).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()?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return dirs;
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
|
|
@ -247,8 +344,13 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path:
|
|||
})
|
||||
}
|
||||
|
||||
/// `s` lowercased, without anything but letters and digits.
|
||||
fn norm_chars(s: &str) -> impl Iterator<Item = char> + '_ {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase)
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
norm_chars(s).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name (400 = regular).
|
||||
|
|
@ -278,7 +380,8 @@ fn style_italic(style: &str) -> bool {
|
|||
}
|
||||
|
||||
impl FontDb {
|
||||
fn new_bundled() -> Self {
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
for data in BUNDLED {
|
||||
for (i, family, style) in enumerate_faces(data) {
|
||||
|
|
@ -290,8 +393,12 @@ impl FontDb {
|
|||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
font_dirs,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
family_cache: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
|
|
@ -308,34 +415,65 @@ impl FontDb {
|
|||
let _ = on;
|
||||
}
|
||||
|
||||
/// Process-wide database preloaded with the bundled fonts.
|
||||
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(FontDb::new_bundled)
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
pub fn families(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone()));
|
||||
v.sort_by_key(|a| a.to_lowercase());
|
||||
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
v
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Catalog> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Style names available for `family` (Regular first, then by weight).
|
||||
/// Fonts were added or the catalog changed: cached family lists are stale.
|
||||
fn changed(&self) {
|
||||
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A number that changes whenever the available fonts do (fonts added, system fonts
|
||||
/// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus installed system fonts), sorted and deduplicated.
|
||||
pub fn families(&self) -> Vec<String> {
|
||||
self.family_list().to_vec()
|
||||
}
|
||||
|
||||
/// [`FontDb::families`], shared: cheap to call every frame.
|
||||
pub fn family_list(&self) -> Arc<[String]> {
|
||||
let catalog = self.read_catalog();
|
||||
let mut cache = self.family_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(list) = cache.as_ref() {
|
||||
return list.clone();
|
||||
}
|
||||
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
|
||||
v.extend(catalog.values().map(|c| c.name.clone()));
|
||||
v.sort_by_key(|a| a.to_lowercase());
|
||||
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
let list: Arc<[String]> = v.into();
|
||||
*cache = Some(list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
/// Style names available for `family`: upright styles by weight, then italics.
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).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()));
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
for (style, _) in &c.faces {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) {
|
||||
v.push((style_italic(style), style_weight(style), style.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
|
||||
|
|
@ -350,49 +488,66 @@ impl FontDb {
|
|||
}
|
||||
|
||||
/// [`FontDb::add_font`] for the bytes of the file at `path` (when known).
|
||||
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&std::path::Path>) -> usize {
|
||||
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {
|
||||
let data = Arc::new(bytes);
|
||||
let mut added = 0;
|
||||
for (i, family, style) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(std::path::Path::to_path_buf)) {
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) {
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
self.changed();
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Scan the platform's font directories and catalog the faces found (native only; not called by
|
||||
/// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the
|
||||
/// number of faces cataloged.
|
||||
/// Catalog the installed fonts unless that has been done: the first caller scans the font
|
||||
/// folders, any other waits for that scan to finish. A no-op on wasm.
|
||||
fn ensure_catalog(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.cataloged.get_or_init(|| {
|
||||
self.scan_font_dirs();
|
||||
});
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts on a background thread, so the first lookup by family name
|
||||
/// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are
|
||||
/// cataloged, and on wasm.
|
||||
pub fn scan_in_background(&'static self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.cataloged.get().is_none() {
|
||||
// A failed spawn leaves the scan to the first lookup that needs it.
|
||||
let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan the font folders again (fonts installed or removed since), cataloging the faces
|
||||
/// found (native only). Font data is loaded when a cataloged family is first resolved.
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
let mut stack = dirs;
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
first.unwrap_or_else(|| self.scan_font_dirs())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut catalog = Catalog::new();
|
||||
let mut n = 0;
|
||||
let mut stack = self.font_dirs.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
|
|
@ -404,26 +559,29 @@ impl FontDb {
|
|||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
for (_, family, style) in enumerate_faces(&data) {
|
||||
found.push(CatalogEntry { family, style, path: p.clone() });
|
||||
for (family, style) in file_face_names(&p) {
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
entry.faces.push((style, p.clone()));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
self.changed();
|
||||
n
|
||||
}
|
||||
|
||||
/// Load the files of the installed `family`. Returns whether any face was added.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let paths: Vec<std::path::PathBuf> = {
|
||||
let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner());
|
||||
let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect();
|
||||
p.dedup();
|
||||
p
|
||||
};
|
||||
let mut paths: Vec<PathBuf> =
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
let mut any = false;
|
||||
for p in paths {
|
||||
if let Ok(data) = std::fs::read(&p) {
|
||||
|
|
@ -434,19 +592,33 @@ impl FontDb {
|
|||
}
|
||||
|
||||
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
|
||||
/// Source Sans 3 Regular. `None` only if no font at all is loaded (the bundled fonts failed to
|
||||
/// parse), in which case text has no glyphs.
|
||||
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None`
|
||||
/// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has
|
||||
/// no glyphs.
|
||||
pub fn face(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
let found = self.find(family, style);
|
||||
if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) {
|
||||
return found;
|
||||
}
|
||||
// The family, or this style of it, is installed but not loaded yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
if (found.is_none() || self.is_cataloged(family, style))
|
||||
&& self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
self.find(FALLBACK_FAMILY, style).or_else(|| self.find(FALLBACK_FAMILY, "Regular")).or_else(|| self.read_faces().first().cloned())
|
||||
found
|
||||
.or_else(|| self.find(FALLBACK_FAMILY, style))
|
||||
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
|
||||
.or_else(|| self.read_faces().first().cloned())
|
||||
}
|
||||
|
||||
/// Is `style` of `family` among the installed fonts?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_cataloged(&self, family: &str, style: &str) -> bool {
|
||||
let ns = norm(style);
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns))
|
||||
}
|
||||
|
||||
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
|
||||
|
|
@ -454,12 +626,22 @@ impl FontDb {
|
|||
self.read_faces().iter().find(|f| f.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded)?
|
||||
/// The available family `name` names, ignoring case and anything but letters and digits, as
|
||||
/// PostScript names write families ("MicrosoftYaHei" is Microsoft YaHei).
|
||||
pub fn find_family(&self, name: &str) -> Option<String> {
|
||||
self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned()
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
}
|
||||
|
||||
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
|
|
@ -481,7 +663,7 @@ impl FontDb {
|
|||
}
|
||||
|
||||
/// First face (fallback family first, then load order) that covers `c`; on native, system
|
||||
/// fonts are cataloged and loaded lazily the first time no loaded face covers a character.
|
||||
/// fonts are loaded lazily the first time no loaded face covers a character.
|
||||
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback(c, exclude) {
|
||||
return Some(f);
|
||||
|
|
@ -508,29 +690,18 @@ impl FontDb {
|
|||
return false;
|
||||
}
|
||||
{
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
if !sys.scanned {
|
||||
sys.scanned = true;
|
||||
if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
|
||||
drop(sys);
|
||||
self.load_system_fonts();
|
||||
}
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let cataloged: Vec<String> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
if covered(self) {
|
||||
return true;
|
||||
}
|
||||
if !self.is_loaded(fam) && self.load_cataloged(fam) && covered(self) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mod shape;
|
|||
pub mod thread;
|
||||
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
|
@ -366,4 +366,6 @@ mod tests_scripts;
|
|||
#[cfg(test)]
|
||||
mod tests_snap;
|
||||
#[cfg(test)]
|
||||
mod tests_sysfonts;
|
||||
#[cfg(test)]
|
||||
mod tests_typo;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ use kurbo::{Affine, Shape};
|
|||
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
/// The bundled fonts only: what these tests measure doesn't change with the fonts installed.
|
||||
fn db() -> &'static FontDb {
|
||||
FontDb::global()
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(vec![]))
|
||||
}
|
||||
|
||||
fn style(size: f64) -> CharStyle {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Installed system fonts: found by family name by every lookup, whatever ran before it (#130).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// The fonts in `faces` as one collection (TTC) file.
|
||||
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = b"ttcf".to_vec();
|
||||
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
|
||||
let mut base = 12 + 4 * faces.len();
|
||||
for f in faces {
|
||||
out.extend_from_slice(&(base as u32).to_be_bytes());
|
||||
base += f.len();
|
||||
}
|
||||
for f in faces {
|
||||
// Table offsets count from the start of the collection.
|
||||
let start = out.len() as u32;
|
||||
let mut f = f.clone();
|
||||
let tables = u16::from_be_bytes([f[4], f[5]]) as usize;
|
||||
for r in 0..tables {
|
||||
let at = 12 + r * 16 + 8;
|
||||
let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + start;
|
||||
f[at..at + 4].copy_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
out.extend_from_slice(&f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A font folder (with a subfolder, a damaged font and a file that isn't a font) holding
|
||||
/// [`FAMILY`] Regular and Bold.
|
||||
fn font_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Sub")).unwrap();
|
||||
std::fs::write(dir.join("Sysfont-Regular.ttf"), renamed("SourceSans3-Regular.ttf")).unwrap();
|
||||
std::fs::write(dir.join("Sub/Sysfont-Bold.TTF"), renamed("SourceSans3-Bold.ttf")).unwrap();
|
||||
std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("readme.txt"), FAMILY).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn has(list: &[String], family: &str) -> bool {
|
||||
list.iter().any(|f| f == family)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_lookup_by_name_finds_installed_fonts_in_a_fresh_database() {
|
||||
let dir = font_dir("fresh");
|
||||
// Each lookup is the first thing a new database is asked. A folder listed twice is read once.
|
||||
let db = || FontDb::with_font_dirs(vec![dir.clone(), dir.join("Sub/..")]);
|
||||
let f = db().face("sysfont sans3", "Bold").unwrap();
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), (FAMILY, "Bold"));
|
||||
assert_eq!(f.path().map(std::fs::canonicalize).unwrap().unwrap(), std::fs::canonicalize(dir.join("Sub/Sysfont-Bold.TTF")).unwrap());
|
||||
assert!(db().has_family(FAMILY));
|
||||
assert!(has(&db().families(), FAMILY));
|
||||
assert_eq!(db().styles(FAMILY), ["Regular", "Bold"]);
|
||||
assert_eq!(db().load_system_fonts(), 2);
|
||||
assert_eq!(db().find_family("SysfontSans3").as_deref(), Some(FAMILY), "a PostScript-style name");
|
||||
// Missing fonts stay missing.
|
||||
let db = db();
|
||||
assert!(!db.has_family("No Such Font"));
|
||||
assert_eq!(db.face("No Such Font", "Regular").unwrap().family, FALLBACK_FAMILY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_collections_and_loads_fonts_only_when_used() {
|
||||
let dir = font_dir("collection");
|
||||
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
|
||||
std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap();
|
||||
std::fs::write(dir.join("Sysfont.ttc"), collection(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")])).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]);
|
||||
// Cataloged, not loaded.
|
||||
assert!(!db.is_loaded(FAMILY));
|
||||
let bold = db.face(FAMILY, "Bold").unwrap();
|
||||
assert_eq!((bold.style.as_str(), bold.face_index()), ("Bold", 1));
|
||||
assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescanning_finds_fonts_installed_since() {
|
||||
let dir = font_dir("rescan");
|
||||
let later = dir.join("Later");
|
||||
let db = FontDb::with_font_dirs(vec![later.clone()]);
|
||||
assert!(!db.has_family(FAMILY));
|
||||
let (generation, families) = (db.generation(), db.family_list());
|
||||
std::fs::create_dir_all(&later).unwrap();
|
||||
std::fs::copy(dir.join("Sysfont-Regular.ttf"), later.join("Sysfont-Regular.ttf")).unwrap();
|
||||
assert!(!db.has_family(FAMILY), "the folders are scanned once, not on every miss");
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
assert!(db.has_family(FAMILY) && has(&db.family_list(), FAMILY) && !has(&families, FAMILY));
|
||||
assert_ne!(db.generation(), generation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_scan_serves_the_first_lookup() {
|
||||
let dir = font_dir("background");
|
||||
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
|
||||
db.scan_in_background();
|
||||
// Waits for the scan when it is still running.
|
||||
assert!(has(&db.families(), FAMILY));
|
||||
assert_eq!(db.load_system_fonts(), 2, "a rescan catalogs the two faces again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_list_is_shared_until_fonts_change() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let a = db.family_list();
|
||||
assert!(Arc::ptr_eq(&a, &db.family_list()));
|
||||
assert!(db.add_font(renamed("SourceSans3-Regular.ttf")) > 0);
|
||||
let b = db.family_list();
|
||||
assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
let dir = font_dir("styles");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
// The family is loaded with only its Regular face (as when a bundled family is also installed).
|
||||
assert_eq!(db.add_font(renamed("SourceSans3-Regular.ttf")), 1);
|
||||
assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Bold");
|
||||
// A style nobody has still gets the closest one.
|
||||
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! A new session finds the installed system fonts by family name (#130). One test in its own
|
||||
//! process, so the process-wide database starts fresh, as it does when the app starts.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use vectorcraft_text::{FontDb, system_font_dirs};
|
||||
|
||||
#[test]
|
||||
fn a_fresh_session_finds_installed_fonts_by_name() {
|
||||
let bundled = FontDb::with_font_dirs(vec![]).families();
|
||||
let probe = FontDb::with_font_dirs(system_font_dirs());
|
||||
probe.load_system_fonts();
|
||||
let installed = probe.families();
|
||||
let Some(family) = installed.iter().find(|f| !bundled.contains(f)) else {
|
||||
eprintln!("no system fonts installed: nothing to check");
|
||||
return;
|
||||
};
|
||||
// The first lookup in the session: nothing has scanned the font folders yet.
|
||||
let face = FontDb::global().face(family, "Regular").unwrap();
|
||||
assert_eq!(&face.family, family);
|
||||
assert!(FontDb::global().families().contains(family));
|
||||
}
|
||||
|
|
@ -296,6 +296,8 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
|
|||
|
||||
impl VectorcraftApp {
|
||||
pub fn new(mut session: Session, services: Services) -> Self {
|
||||
// The font menus and the first file opened need the installed fonts: catalog them now.
|
||||
vectorcraft_text::FontDb::global().scan_in_background();
|
||||
if let Some(store) = &services.recovery_store {
|
||||
session.recovery.set_store(store.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let fnames: Vec<&str> = fams.iter().map(String::as_str).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "gl-family", &family, &fnames, (w * 0.6).max(80.0)) {
|
||||
let st = db.styles(fnames[i]).into_iter().next().unwrap_or_else(|| "Regular".into());
|
||||
let st = db.face(fnames[i], "Regular").map_or_else(|| "Regular".into(), |f| f.style.clone());
|
||||
set_pstate(ui.ctx(), "gl-font", Some((fnames[i].to_string(), st)));
|
||||
}
|
||||
let styles = db.styles(&family);
|
||||
|
|
|
|||
Loading…
Reference in New Issue