Skip to main content

kobo_core/rendering/text_render/
fonts.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3use fontdue::Font;
4use rustybuzz::Face;
5use std::sync::atomic::{AtomicUsize, Ordering};
6use std::sync::RwLock;
7
8use super::Script;
9
10pub(crate) const FONT_ID_DEFAULT: u8 = 0;
11/// Sits above every script id (`Script::font_id` = variant index + 1).
12pub(crate) const FONT_ID_MONO: u8 = Script::SLOTS as u8 + 1;
13
14pub(crate) struct FontEntry {
15    pub face: Face<'static>,
16    pub body: Font,
17    pub id: u8,
18}
19
20static FONT_INSTALL_COUNTER: AtomicUsize = AtomicUsize::new(0);
21
22pub fn font_install_count() -> usize {
23    FONT_INSTALL_COUNTER.load(Ordering::Relaxed)
24}
25
26/// How a run of text should be set.
27///
28/// `bold` and `italic` are synthesised from the regular face rather than loaded
29/// as separate files. That is a deliberate choice, not a shortcut: this is a
30/// Bangla-first reader, so a bundled Latin bold would leave Bengali, Devanagari,
31/// Arabic, CJK and Thai with no emphasis at all, and mixing a second family's
32/// letterforms into a Noto line reads as two fonts colliding mid-sentence.
33/// Synthesis keeps every script consistent with its own body face and costs no
34/// binary size.
35///
36/// `mono` is the opposite case and *is* a real font: code is meant to look
37/// different from prose, and no amount of emboldening gives a proportional face
38/// the fixed advances that make columns line up.
39#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
40pub struct TextStyle {
41    pub bold: bool,
42    pub italic: bool,
43    pub mono: bool,
44    pub link: bool,
45}
46
47impl TextStyle {
48    pub const PLAIN: TextStyle = TextStyle {
49        bold: false,
50        italic: false,
51        mono: false,
52        link: false,
53    };
54
55    pub fn is_plain(self) -> bool {
56        self == Self::PLAIN
57    }
58
59    /// Compact key for the width cache.
60    pub(crate) fn bits(self) -> u8 {
61        (self.bold as u8)
62            | ((self.italic as u8) << 1)
63            | ((self.mono as u8) << 2)
64            | ((self.link as u8) << 3)
65    }
66}
67
68pub(crate) struct FontRegistry {
69    pub default: FontEntry,
70    pub mono: FontEntry,
71    /// One slot per `Script`, indexed by `Script::slot()`. A table rather than
72    /// named fields so adding a script touches only the enum and the font spec
73    /// list - there is no per-script wiring to forget here.
74    pub scripts: [RwLock<Option<FontEntry>>; Script::SLOTS],
75}
76
77static FONTS: std::sync::OnceLock<FontRegistry> = std::sync::OnceLock::new();
78
79type GlyphMap = std::collections::HashMap<(u8, u16, u32), (fontdue::Metrics, Vec<u8>)>;
80
81pub(crate) static GLYPH_CACHE: std::sync::OnceLock<std::sync::Mutex<GlyphMap>> =
82    std::sync::OnceLock::new();
83
84pub(crate) fn glyph_cache() -> &'static std::sync::Mutex<GlyphMap> {
85    GLYPH_CACHE.get_or_init(std::sync::Mutex::default)
86}
87
88fn load_font(data: &'static [u8], id: u8) -> FontEntry {
89    let face = Face::from_slice(data, 0).expect("font parse");
90    let body = Font::from_bytes(data, fontdue::FontSettings::default()).expect("fontdue load");
91    FontEntry { face, body, id }
92}
93
94pub(crate) fn fonts() -> &'static FontRegistry {
95    FONTS.get_or_init(|| FontRegistry {
96        default: load_font(super::FONT_LATIN, FONT_ID_DEFAULT),
97        mono: load_font(super::FONT_MONO, FONT_ID_MONO),
98        scripts: std::array::from_fn(|_| RwLock::new(None)),
99    })
100}
101
102pub fn install_font(script: Script, data: Vec<u8>) -> bool {
103    // Latin is the embedded default and Other has no face of its own.
104    if matches!(script, Script::Latin | Script::Other) {
105        return false;
106    }
107    let entry = match load_font_owned(data, script.font_id()) {
108        Some(e) => e,
109        None => return false,
110    };
111    let reg = fonts();
112    if let Ok(mut guard) = reg.scripts[script.slot()].write() {
113        *guard = Some(entry);
114    }
115    super::clear_width_cache();
116    FONT_INSTALL_COUNTER.fetch_add(1, Ordering::Relaxed);
117    true
118}
119
120fn load_font_owned(data: Vec<u8>, id: u8) -> Option<FontEntry> {
121    let data_boxed: Box<[u8]> = data.into_boxed_slice();
122    let leaked: &'static [u8] = Box::leak(data_boxed);
123    let face = Face::from_slice(leaked, 0)?;
124    let body = Font::from_bytes(leaked, fontdue::FontSettings::default()).ok()?;
125    Some(FontEntry { face, body, id })
126}
127
128pub fn font_covers(data: &[u8], probe_chars: &str) -> bool {
129    let face = match Face::from_slice(data, 0) {
130        Some(f) => f,
131        None => return false,
132    };
133    probe_chars.chars().all(|c| face.glyph_index(c).is_some())
134}
135
136/// Face for a run: the monospace face when the style asks for it, otherwise the
137/// script's own face. Bold and italic do not choose a face -- they are applied
138/// to the rasterised glyph, so they compose with every script.
139pub(crate) fn font_for(script: Script, style: TextStyle) -> &'static FontEntry {
140    if style.mono {
141        // Latin-only face. Anything it cannot draw falls through to the normal
142        // per-character fallback in the blit loop, so a Bengali comment inside a
143        // code block still renders.
144        return &fonts().mono;
145    }
146    font_for_script(script)
147}
148
149pub(crate) fn font_for_script(script: Script) -> &'static FontEntry {
150    let reg = fonts();
151    if matches!(script, Script::Latin | Script::Other) {
152        return &reg.default;
153    }
154    let slot = &reg.scripts[script.slot()];
155    if let Ok(guard) = slot.read() {
156        if let Some(ref entry) = *guard {
157            // SAFETY: FontEntry is stored in a 'static RwLock inside a 'static
158            // OnceLock. The entry is never removed once installed, so the
159            // reference is valid for the program lifetime.
160            unsafe {
161                return &*(entry as *const FontEntry);
162            }
163        }
164    }
165    &reg.default
166}
167
168pub(crate) fn fallback_for_char(c: char) -> Option<(&'static FontEntry, u16)> {
169    let reg = fonts();
170    // Every installed face is a candidate, so a stray character from any script
171    // still renders inside a run set in another.
172    for slot in reg.scripts.iter() {
173        if let Ok(g) = slot.read() {
174            if let Some(entry) = g.as_ref() {
175                if let Some(gid) = entry.face.glyph_index(c) {
176                    // SAFETY: FontEntry is stored in a 'static RwLock inside a
177                    // 'static OnceLock; it is never removed once installed.
178                    let e: &'static FontEntry = unsafe { &*(entry as *const FontEntry) };
179                    return Some((e, gid.0));
180                }
181            }
182        }
183    }
184    reg.default
185        .face
186        .glyph_index(c)
187        .map(|gid| (&reg.default, gid.0))
188}
189
190pub fn has_font_for(script: Script) -> bool {
191    // Latin is embedded; Other has no dedicated face and falls back per glyph.
192    if matches!(script, Script::Latin | Script::Other) {
193        return true;
194    }
195    fonts().scripts[script.slot()]
196        .read()
197        .map(|g| g.is_some())
198        .unwrap_or(false)
199}
200
201pub fn line_height_for(script: Script, px_size: f32) -> usize {
202    let fd = font_for_script(script);
203    let lm = fd.body.horizontal_line_metrics(px_size);
204    let h = match lm {
205        Some(m) => m.ascent - m.descent + m.line_gap,
206        None => px_size * 1.2,
207    };
208    h.max(1.0) as usize
209}
210
211pub fn line_height(px_size: f32) -> usize {
212    line_height_for(Script::Latin, px_size)
213}
214
215pub fn word_width(word: &str, px_size: f32) -> f32 {
216    word_width_styled(word, px_size, TextStyle::PLAIN)
217}
218
219/// Width of a run as it will actually be drawn.
220///
221/// Only `mono` changes the answer: it swaps the face, and fixed advances differ
222/// from proportional ones. Synthetic bold thickens strokes without moving the
223/// pen and synthetic italic shears in place, so both keep the regular advances
224/// -- which is what stops emphasis from reflowing a paragraph.
225pub fn word_width_styled(word: &str, px_size: f32, style: TextStyle) -> f32 {
226    let key = (
227        format!("{}\u{1}{}", word, style.bits()),
228        (px_size * 100.0) as u32,
229    );
230    let cache = super::width_cache();
231    if let Ok(c) = cache.lock() {
232        if let Some(&w) = c.get(&key) {
233            return w;
234        }
235    }
236    let fd = font_for(super::detect_script(word), style);
237    let scale = px_size / fd.face.units_per_em() as f32;
238    let mut ub = rustybuzz::UnicodeBuffer::new();
239    ub.push_str(word);
240    let dir = if super::detect_script(word).is_rtl() {
241        rustybuzz::Direction::RightToLeft
242    } else {
243        rustybuzz::Direction::LeftToRight
244    };
245    ub.set_direction(dir);
246    let gb = rustybuzz::shape(&fd.face, &[], ub);
247    let w = gb
248        .glyph_positions()
249        .iter()
250        .map(|p| p.x_advance as f32)
251        .sum::<f32>()
252        * scale;
253    if let Ok(mut c) = cache.lock() {
254        c.insert(key, w);
255    }
256    w
257}
258
259/// Per-character advance widths for a run, from a single HarfBuzz shape call.
260///
261/// Replaces thousands of individual `word_width_styled` calls for CJK text,
262/// which froze the UI on the Kobo's ARM CPU. The output maps 1:1 to the
263/// input's `char_indices()`, so the caller can wrap by accumulating widths.
264pub fn char_widths_batch(text: &str, px_size: f32, style: TextStyle) -> Vec<f32> {
265    let fd = font_for(super::detect_script(text), style);
266    let scale = px_size / fd.face.units_per_em() as f32;
267    let mut ub = rustybuzz::UnicodeBuffer::new();
268    ub.push_str(text);
269    ub.set_direction(rustybuzz::Direction::LeftToRight);
270    let gb = rustybuzz::shape(&fd.face, &[], ub);
271    let clusters = gb.glyph_infos();
272    let positions = gb.glyph_positions();
273    let mut out = vec![0.0f32; text.chars().count()];
274    let mut ci = 0usize;
275    for (gi, pos) in positions.iter().enumerate() {
276        let adv = pos.x_advance as f32 * scale;
277        let cluster = clusters.get(gi).map(|c| c.cluster as usize).unwrap_or(0);
278        let char_idx = text[..cluster.min(text.len())].chars().count();
279        if char_idx < out.len() {
280            ci = char_idx;
281        }
282        out[ci] += adv;
283    }
284    out
285}