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
}