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()
}