ltk/system_fonts.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Process-wide font resolution for both backends: the primary UI font
//! ([`default_handle`] / [`primary_handle`], loaded once from the
//! [`SYSTEM_FONT_CANDIDATES`] chain) and lazy per-glyph fallback.
//!
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
//! covers Latin and a portion of extended Latin; everything outside
//! that band — Cyrillic, Devanagari, Arabic, Hebrew, Thai, the CJK
//! ideographic block — relies on a chain of system Noto fonts loaded
//! on demand: a fallback file is read off disk the first time some
//! glyph asks for it and the resulting `Arc<FontFace>` is cached
//! process-wide.
//!
//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its
//! own `OnceLock<Option<Arc<FontFace>>>` slot. `Some(font)` means the
//! file was read and parsed successfully; `None` means it's missing
//! or unparseable, locked in for the rest of the process so we don't
//! re-`stat` the same path on every glyph miss. The `OnceLock`
//! handles concurrent first-loads correctly: at most one thread runs
//! the closure for a given slot, the rest wait for the result.
//!
//! Renderers (`SoftwareCanvas` and `GlesCanvas`) consult this module
//! through [`lookup`] in their `font_for_char` paths.
use std::sync::{ Arc, OnceLock };
use crate::font_face::FontFace;
/// Bytes-aware font handle. The `font` is what glyphs rasterise
/// against; `bytes` is the raw OpenType / TrueType buffer that
/// rustybuzz (HarfBuzz) requires for shaping. Both name the same
/// buffer — the face holds its own `Arc` so it can pull outlines in
/// later — and are kept side by side so shaping needs no second read.
///
/// `face` is the TTC sub-face index (0 for single-face files, the
/// face index for collections like Noto Sans CJK).
#[ derive( Clone ) ]
pub struct FontHandle
{
pub font: Arc<FontFace>,
pub bytes: Arc<Vec<u8>>,
pub face: u32,
}
impl FontHandle
{
/// The handle of a face, with the bytes it was parsed from.
pub fn of( font: Arc<FontFace> ) -> Self
{
let bytes = Arc::clone( font.data() );
let face = font.face_index();
Self { font, bytes, face }
}
}
/// Per-script fallback font path with the TrueType-collection face
/// index to load (most files are single-face → 0; CJK `.ttc` archives
/// carry many faces, see the SC face on the canonical Adobe-built
/// `NotoSansCJK-Regular.ttc`). A path that does not exist is looked up by
/// its file name in the resource directory's `fonts/`, which is where a
/// browser finds the fallbacks a page bundles.
struct FallbackFontSpec
{
path: &'static str,
face: u32,
}
/// Ordered fallback chain consulted on a glyph miss. Order matters:
/// the first slot whose font owns a non-zero glyph index for the
/// codepoint wins, so the broadest families come first (Noto Sans
/// covers Cyrillic, Greek, extended Latin) and the script-specific
/// and CJK packs trail. DejaVu goes ahead of the CJK collection: it owns
/// the geometric shapes, arrows and box drawing that would otherwise
/// pull a 19 MB collection in for a single bullet.
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
&[
// Noto Sans — Cyrillic, Greek, extended Latin.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSans-Regular.ttf", face: 0 },
// Devanagari (Hindi).
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
// Arabic, Hebrew, Thai — common scripts cheap to keep.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", face: 0 },
// DejaVu — broad-but-shallow coverage of most scripts, plus the
// symbol, arrow and box-drawing blocks. Ships almost everywhere.
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
// Symbols DejaVu lacks: media controls, extra arrows and shapes.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf", face: 0 },
// Colour emoji, flags included; its glyphs are bitmaps (FontFace::color_glyph).
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoColorEmoji.ttf", face: 0 },
// CJK packs ship as `.ttc`. The collection layout on the canonical
// Adobe-built `NotoSansCJK-Regular.ttc` is 0=JP, 1=KR, 2=SC, 3=TC,
// 4=HK; we want CJK shared ideographs available in any locale, so
// any of the faces is fine — pick SC (face 2) as the broadest
// baseline. Last, because probing a slot reads its whole file, and
// this one is 19 MB.
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
];
/// The same chain over Android's `/system/fonts`, whose file names have held across releases; Devanagari changed to a variable font in Android 12.
#[ cfg( target_os = "android" ) ]
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
&[
FallbackFontSpec { path: "/system/fonts/Roboto-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansDevanagari-VF.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansDevanagari-Regular.otf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoNaskhArabic-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansHebrew-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansThai-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansSymbols-Regular-Subsetted.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansSymbols-Regular-Subsetted2.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoColorEmoji.ttf", face: 0 },
FallbackFontSpec { path: "/system/fonts/NotoSansCJK-Regular.ttc", face: 2 },
];
/// The same chain over the fonts every Windows 10 and 11 installs; Nirmala UI is a collection since Windows 11.
#[ cfg( windows ) ]
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
&[
FallbackFontSpec { path: "C:\\Windows\\Fonts\\segoeui.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\Nirmala.ttc", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\Nirmala.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\LeelawUI.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\seguisym.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\seguiemj.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\malgun.ttf", face: 0 },
FallbackFontSpec { path: "C:\\Windows\\Fonts\\msyh.ttc", face: 0 },
];
/// Per-slot lazy state, two stages so probing a slot for coverage never
/// builds a face: `bytes` is the file read once, `font` is the face
/// built only when a glyph in this slot is actually needed. `None` in a
/// resolved stage is a sticky negative result.
struct Slot
{
bytes: OnceLock<Option<Arc<Vec<u8>>>>,
font: OnceLock<Option<FontHandle>>,
}
fn slots() -> &'static [ Slot ]
{
static SLOTS: OnceLock<Vec<Slot>> = OnceLock::new();
SLOTS.get_or_init( ||
{
( 0..FALLBACK_FONT_CANDIDATES.len() )
.map( |_| Slot { bytes: OnceLock::new(), font: OnceLock::new() } )
.collect()
} )
}
fn slot_bytes( idx: usize ) -> Option<Arc<Vec<u8>>>
{
slots()[ idx ].bytes.get_or_init( ||
{
crate::resources::read_font( std::path::Path::new( FALLBACK_FONT_CANDIDATES[ idx ].path ) ).ok().map( Arc::new )
} )
.clone()
}
/// Whether slot `idx` owns a glyph for `ch`, answered from the `cmap`
/// alone: ttf-parser only touches the tables it is asked for, so this
/// stays cheap even for a multi-megabyte CJK collection.
fn slot_covers( idx: usize, ch: char ) -> bool
{
let Some( bytes ) = slot_bytes( idx ) else { return false };
let face = FALLBACK_FONT_CANDIDATES[ idx ].face;
rustybuzz::ttf_parser::Face::parse( &bytes, face )
.ok()
.and_then( |f| f.glyph_index( ch ) )
.is_some()
}
/// Build (once) the face for slot `idx`. The raw bytes are preserved
/// inside the handle so the same `Arc<Vec<u8>>` can be handed to
/// rustybuzz for shaping without re-reading the file.
fn slot_handle( idx: usize ) -> Option<FontHandle>
{
slots()[ idx ].font.get_or_init( ||
{
let bytes = slot_bytes( idx )?;
let face = FALLBACK_FONT_CANDIDATES[ idx ].face;
let font = FontFace::from_bytes( Arc::clone( &bytes ), face )?;
Some( FontHandle { font: Arc::new( font ), bytes, face } )
} )
.clone()
}
/// Find the first fallback font that has a non-zero glyph index for
/// `ch`, loading it from disk on the first hit and caching the
/// `Arc<FontFace>` for the rest of the process. Returns `None` if no
/// installed fallback covers the codepoint — the caller then paints
/// the primary font's `.notdef` rather than dropping the glyph.
pub fn lookup( ch: char ) -> Option<Arc<FontFace>>
{
lookup_handle( ch ).map( |h| h.font )
}
/// System-font search chain for the primary UI font, ordered by preference.
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
// depends on. Listed first so Sora wins as the default font
// whenever the package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// None on Android and Windows: the embedded Sora is ltk's face there, not the system's Roboto or Segoe UI.
#[ cfg( any( target_os = "android", windows ) ) ]
const SYSTEM_FONT_CANDIDATES: &[&str] = &[];
/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or
/// `None` if none exist. Used by the font registry and by tests that skip
/// gracefully on images without the usual fonts installed.
pub ( crate ) fn find_font_opt() -> Option<String>
{
SYSTEM_FONT_CANDIDATES.iter()
.find( |p| crate::vfs::is_file( p ) )
.copied()
.map( str::to_string )
}
/// Load the bytes of a default system font. Tries the candidate chain via
/// [`find_font_opt`]; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB, OFL 1.1)
/// when nothing matches or the file cannot be read. Always returns usable bytes
/// so canvas construction never panics on a system without the expected fonts.
pub ( crate ) fn load_default_font_bytes() -> Vec<u8>
{
if let Some( path ) = find_font_opt()
{
if let Ok( bytes ) = crate::vfs::read( &path )
{
return bytes;
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}
/// The process-wide primary UI font handle — the single default every canvas
/// loads. Cached so a layer shell bringing up several surfaces parses the small
/// Sora face once rather than per surface. The per-glyph fallback chain (Noto
/// Sans / CJK / Devanagari / …) is loaded lazily through [`lookup`], not here.
pub ( crate ) fn default_handle() -> FontHandle
{
static PRIMARY: OnceLock<FontHandle> = OnceLock::new();
PRIMARY.get_or_init( ||
{
let bytes = Arc::new( load_default_font_bytes() );
let font = FontFace::from_bytes( Arc::clone( &bytes ), 0 ).expect( "primary font parses" );
FontHandle { font: Arc::new( font ), bytes, face: 0 }
} ).clone()
}
/// The process-wide primary UI font — the same default a canvas loads — for
/// standalone text measurement (e.g. an embedded measure pass with no live
/// canvas). Falls back to the bundled font when no system font is found.
pub fn primary_handle() -> FontHandle
{
default_handle()
}
/// Bytes-aware variant of [`lookup`]. Returns the full
/// [`FontHandle`] (face + raw bytes + face index) so callers that
/// need to invoke a HarfBuzz-style shaper can do so without
/// re-reading the font file.
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
{
( 0..FALLBACK_FONT_CANDIDATES.len() )
.find( |&idx| slot_covers( idx, ch ) )
.and_then( slot_handle )
}
/// The monospace face, as found among the app's resources, which a page and an APK carry, and then among the system's fonts.
const MONOSPACE_BUNDLED: &str = "DejaVuSansMono.ttf";
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
const MONOSPACE_CANDIDATES: &[&str] =
&[
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
"/usr/share/fonts/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/hack/Hack-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf",
];
#[ cfg( target_os = "android" ) ]
const MONOSPACE_CANDIDATES: &[&str] =
&[
"/system/fonts/DroidSansMono.ttf",
"/system/fonts/CutiveMono.ttf",
];
#[ cfg( windows ) ]
const MONOSPACE_CANDIDATES: &[&str] =
&[
"C:\\Windows\\Fonts\\consola.ttf",
"C:\\Windows\\Fonts\\cour.ttf",
];
/// A monospace face for text that lines up in columns, such as line numbers or a terminal; `None` when none is installed.
pub fn monospace() -> Option<Arc<FontFace>>
{
static FACE: OnceLock<Option<Arc<FontFace>>> = OnceLock::new();
FACE.get_or_init( ||
{
let bundled = crate::resource_dir().map( |dir| dir.join( "fonts" ).join( MONOSPACE_BUNDLED ) );
let path = bundled.into_iter()
.chain( MONOSPACE_CANDIDATES.iter().map( std::path::PathBuf::from ) )
.find( |path| crate::vfs::is_file( path ) )?;
let bytes = crate::vfs::read( path ).ok()?;
FontFace::from_bytes( Arc::new( bytes ), 0 ).map( Arc::new )
} ).clone()
}