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
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Lazy per-glyph fallback font resolution.
//!
//! 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<Font>` is cached
//! process-wide.
//!
//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its
//! own `OnceLock<Option<Arc<Font>>>` 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 fontdue::{ Font, FontSettings };

/// Bytes-aware font handle. The `font` is what fontdue rasterises
/// against; `bytes` is the raw OpenType / TrueType buffer that
/// rustybuzz (HarfBuzz) requires for shaping. They are owned
/// separately because fontdue does not expose its internal byte
/// buffer — we keep both alive in lockstep instead.
///
/// `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<Font>,
	pub bytes: Arc<Vec<u8>>,
	pub face:  u32,
}

/// Per-script fallback font path with the TrueType-collection face
/// index fontdue should 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`).
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
/// + CJK packs trail. DejaVu is the last resort — most distros carry
/// it under one path or another.
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 },

	// 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. ~30 MB on disk; under the lazy loader this only fires
	// when a user-visible string actually contains a CJK codepoint.
	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 },

	// Last resort — DejaVu has broad-but-shallow coverage of most
	// scripts and ships almost everywhere.
	FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",            face: 0 },
];

/// Per-slot lazy state. `Vec` length matches
/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves
/// independently so the act of looking up a Devanagari codepoint
/// doesn't drag the CJK pack into memory. `None` inside a resolved
/// slot means the file was missing or fontdue rejected it — a sticky
/// negative result so subsequent misses skip the slot in O(1).
fn slots() -> &'static [ OnceLock<Option<FontHandle>> ]
{
	static SLOTS: OnceLock<Vec<OnceLock<Option<FontHandle>>>> = OnceLock::new();
	SLOTS.get_or_init( ||
	{
		( 0..FALLBACK_FONT_CANDIDATES.len() )
			.map( |_| OnceLock::new() )
			.collect()
	} )
}

/// Try to load and parse the fallback font at slot `idx`. Each
/// `OnceLock` wraps `Option<FontHandle>` so a missing or malformed
/// file is recorded as `None` and never re-attempted. 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>
{
	let slot = &slots()[ idx ];
	slot.get_or_init( ||
	{
		let spec  = &FALLBACK_FONT_CANDIDATES[ idx ];
		let bytes = std::fs::read( spec.path ).ok()?;
		let opts  = FontSettings { collection_index: spec.face, ..FontSettings::default() };
		let font  = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
		Some( FontHandle
		{
			font:  Arc::new( font ),
			bytes: Arc::new( bytes ),
			face:  spec.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<Font>` 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.
///
/// Side effect: walking the chain may load and cache a slot even if
/// it doesn't end up covering `ch` (`lookup_glyph_index` reads the
/// `cmap` table, which requires the font to be parsed). That's
/// acceptable — the slot is cached on the first encounter regardless,
/// and most coverage gaps in early slots are the small Noto Sans
/// scripts (Devanagari, Arabic, …) whose total weight is a fraction
/// of the CJK pack everyone was paying for unconditionally.
pub fn lookup( ch: char ) -> Option<Arc<Font>>
{
	lookup_handle( ch ).map( |h| h.font )
}

/// Bytes-aware variant of [`lookup`]. Returns the full
/// [`FontHandle`] (fontdue handle + 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>
{
	for idx in 0..FALLBACK_FONT_CANDIDATES.len()
	{
		let Some( handle ) = slot_handle( idx ) else { continue };
		if handle.font.lookup_glyph_index( ch ) != 0
		{
			return Some( handle );
		}
	}
	None
}