Skip to main content

kobo_core/rendering/
text_render.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3pub mod blit;
4pub mod fonts;
5
6pub use blit::*;
7pub use fonts::*;
8
9const FONT_LATIN: &[u8] = include_bytes!("../../fonts/NotoSansLatin.ttf");
10/// Monospace face for code listings. DejaVu Sans Mono, under the DejaVu Fonts
11/// License (see `fonts/LICENSE-DejaVu.txt`), which permits redistribution
12/// including inside a binary.
13const FONT_MONO: &[u8] = include_bytes!("../../fonts/DejaVuSansMono.ttf");
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Script {
17    Latin,
18    Bengali,
19    Devanagari,
20    Arabic,
21    Hebrew,
22    Greek,
23    Cyrillic,
24    Georgian,
25    Armenian,
26    Ethiopic,
27    Gujarati,
28    Gurmukhi,
29    Tamil,
30    Telugu,
31    Kannada,
32    Malayalam,
33    Sinhala,
34    Thai,
35    Lao,
36    Khmer,
37    Myanmar,
38    Japanese,
39    Korean,
40    /// Han with no kana or hangul alongside it. Real Japanese prose always
41    /// carries kana, so unaccompanied Han is overwhelmingly Chinese - but the
42    /// book's `dc:language` still wins when it is present.
43    Chinese,
44    Other,
45}
46
47/// Every script the renderer can load a face for. `Latin` is excluded: it is
48/// the embedded default and never occupies a registry slot.
49pub const LOADABLE_SCRIPTS: &[Script] = &[
50    Script::Bengali,
51    Script::Devanagari,
52    Script::Arabic,
53    Script::Hebrew,
54    Script::Greek,
55    Script::Cyrillic,
56    Script::Georgian,
57    Script::Armenian,
58    Script::Ethiopic,
59    Script::Gujarati,
60    Script::Gurmukhi,
61    Script::Tamil,
62    Script::Telugu,
63    Script::Kannada,
64    Script::Malayalam,
65    Script::Sinhala,
66    Script::Thai,
67    Script::Lao,
68    Script::Khmer,
69    Script::Myanmar,
70    Script::Japanese,
71    Script::Korean,
72    Script::Chinese,
73];
74
75impl Script {
76    /// Number of registry slots. Sized to the enum so a new variant cannot
77    /// silently overflow the font table.
78    pub const SLOTS: usize = 24;
79
80    /// Stable index into the font registry.
81    pub fn slot(self) -> usize {
82        self as usize
83    }
84
85    /// Glyph-cache discriminator. 0 is the embedded Latin face, so every script
86    /// is offset by one; the mono face takes the last id.
87    pub fn font_id(self) -> u8 {
88        self as u8 + 1
89    }
90
91    pub fn is_rtl(self) -> bool {
92        matches!(self, Script::Arabic | Script::Hebrew)
93    }
94
95    /// Scripts written without spaces between words, so a line may be broken
96    /// between any two glyphs rather than only at spaces.
97    pub fn uses_word_spacing(self) -> bool {
98        !matches!(
99            self,
100            Script::Japanese
101                | Script::Chinese
102                | Script::Korean
103                | Script::Thai
104                | Script::Lao
105                | Script::Khmer
106                | Script::Myanmar
107        )
108    }
109
110    /// Default Edge TTS locale for the script. Only consulted when the book
111    /// declares no `dc:language`.
112    pub fn lang_tag(self) -> &'static str {
113        match self {
114            Script::Latin => "en-US",
115            Script::Bengali => "bn-BD",
116            Script::Devanagari => "hi-IN",
117            Script::Arabic => "ar-SA",
118            Script::Hebrew => "he-IL",
119            Script::Greek => "el-GR",
120            Script::Cyrillic => "ru-RU",
121            Script::Georgian => "ka-GE",
122            Script::Armenian => "hy-AM",
123            Script::Ethiopic => "am-ET",
124            Script::Gujarati => "gu-IN",
125            Script::Gurmukhi => "pa-IN",
126            Script::Tamil => "ta-IN",
127            Script::Telugu => "te-IN",
128            Script::Kannada => "kn-IN",
129            Script::Malayalam => "ml-IN",
130            Script::Sinhala => "si-LK",
131            Script::Thai => "th-TH",
132            Script::Lao => "lo-LA",
133            Script::Khmer => "km-KH",
134            Script::Myanmar => "my-MM",
135            Script::Japanese => "ja-JP",
136            Script::Korean => "ko-KR",
137            Script::Chinese => "zh-CN",
138            Script::Other => "en-US",
139        }
140    }
141}
142
143/// Script of a single character, ignoring the Han ambiguity.
144fn script_of(code: u32) -> Script {
145    match code {
146        0x0980..=0x09FF => Script::Bengali,
147        0x0900..=0x097F => Script::Devanagari,
148        0x0600..=0x06FF | 0x0750..=0x077F | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => Script::Arabic,
149        0x0590..=0x05FF => Script::Hebrew,
150        0x0370..=0x03FF | 0x1F00..=0x1FFF => Script::Greek,
151        0x0400..=0x052F | 0x2DE0..=0x2DFF | 0xA640..=0xA69F => Script::Cyrillic,
152        0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => Script::Georgian,
153        0x0530..=0x058F => Script::Armenian,
154        0x1200..=0x137F | 0x1380..=0x139F | 0x2D80..=0x2DDF => Script::Ethiopic,
155        0x0A80..=0x0AFF => Script::Gujarati,
156        0x0A00..=0x0A7F => Script::Gurmukhi,
157        0x0B80..=0x0BFF => Script::Tamil,
158        0x0C00..=0x0C7F => Script::Telugu,
159        0x0C80..=0x0CFF => Script::Kannada,
160        0x0D00..=0x0D7F => Script::Malayalam,
161        0x0D80..=0x0DFF => Script::Sinhala,
162        0x0E00..=0x0E7F => Script::Thai,
163        0x0E80..=0x0EFF => Script::Lao,
164        0x1780..=0x17FF | 0x19E0..=0x19FF => Script::Khmer,
165        0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F => Script::Myanmar,
166        // Latin, including Extended Additional so Vietnamese diacritics do not
167        // fall through to `Other` and get read out in English.
168        0x0000..=0x024F | 0x1E00..=0x1EFF | 0x2C60..=0x2C7F => Script::Latin,
169        _ => Script::Other,
170    }
171}
172
173/// Detect the dominant script of a run.
174///
175/// Counts every letter and takes the majority rather than trusting the first
176/// one. Both simpler rules break on real books: "first letter wins" calls a
177/// Bengali chapter Latin because it opens with "Chapter", and "first non-Latin
178/// wins" calls an English paragraph Greek because it quotes one Greek word.
179///
180/// Kana and hangul are decisive regardless of count - they appear in exactly
181/// one language each, and Japanese prose interleaves kana with Han, so the Han
182/// belongs to whichever of the two is present.
183pub fn detect_script(text: &str) -> Script {
184    let mut counts = [0usize; Script::SLOTS];
185    let mut kana = 0usize;
186    let mut hangul = 0usize;
187    let mut han = 0usize;
188
189    for c in text.chars() {
190        if !c.is_alphabetic() {
191            continue;
192        }
193        match c as u32 {
194            0x3040..=0x30FF | 0x31F0..=0x31FF => kana += 1,
195            0xAC00..=0xD7AF | 0x1100..=0x11FF | 0x3130..=0x318F => hangul += 1,
196            0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xF900..=0xFAFF | 0x20000..=0x2A6DF => han += 1,
197            code => counts[script_of(code).slot()] += 1,
198        }
199    }
200
201    // Any kana at all means Japanese; any hangul means Korean. Han on its own
202    // is Chinese, since Japanese never runs long without kana.
203    if kana > 0 {
204        return Script::Japanese;
205    }
206    if hangul > 0 {
207        return Script::Korean;
208    }
209    counts[Script::Chinese.slot()] += han;
210
211    let (best, best_count) = counts
212        .iter()
213        .enumerate()
214        .max_by_key(|&(_, n)| *n)
215        .map(|(i, n)| (i, *n))
216        .unwrap_or((Script::Latin.slot(), 0));
217
218    if best_count == 0 {
219        return Script::Latin;
220    }
221    LOADABLE_SCRIPTS
222        .iter()
223        .copied()
224        .chain(std::iter::once(Script::Latin))
225        .find(|s| s.slot() == best)
226        .unwrap_or(Script::Latin)
227}
228
229static WIDTH_CACHE: std::sync::OnceLock<
230    std::sync::Mutex<std::collections::HashMap<(String, u32), f32>>,
231> = std::sync::OnceLock::new();
232
233pub(crate) fn width_cache(
234) -> &'static std::sync::Mutex<std::collections::HashMap<(String, u32), f32>> {
235    WIDTH_CACHE.get_or_init(std::sync::Mutex::default)
236}
237
238#[allow(dead_code)]
239pub fn clear_width_cache() {
240    if let Ok(mut cache) = width_cache().lock() {
241        cache.clear();
242    }
243}
244
245pub struct DecodedImage {
246    pub rgb: Vec<u8>,
247    pub width: usize,
248    pub height: usize,
249}
250
251pub struct DecodedRgba {
252    pub rgba: Vec<u8>,
253    pub width: usize,
254    pub height: usize,
255}
256
257pub fn decode_image(raw: &[u8], max_w: usize, max_h: usize) -> Option<DecodedImage> {
258    let img = image::load_from_memory(raw).ok()?;
259    let (ow, oh) = (img.width() as usize, img.height() as usize);
260    if ow == 0 || oh == 0 {
261        return None;
262    }
263    let scale = max_w as f32 / ow as f32;
264    let mut nw = max_w;
265    let mut nh = (oh as f32 * scale).round() as usize;
266    if nh == 0 {
267        return None;
268    }
269    if nh > max_h {
270        let hscale = max_h as f32 / nh as f32;
271        nh = max_h;
272        nw = (nw as f32 * hscale).round() as usize;
273        if nw == 0 {
274            return None;
275        }
276    }
277    let resized = img.resize(nw as u32, nh as u32, image::imageops::FilterType::Triangle);
278    let rgb = resized.to_rgb8();
279    let (rw, rh) = (rgb.width() as usize, rgb.height() as usize);
280    Some(DecodedImage {
281        rgb: rgb.into_raw(),
282        width: rw,
283        height: rh,
284    })
285}
286
287pub fn decode_image_rgba(raw: &[u8], max_w: usize, max_h: usize) -> Option<DecodedRgba> {
288    let img = image::load_from_memory(raw).ok()?;
289    let (ow, oh) = (img.width() as usize, img.height() as usize);
290    if ow == 0 || oh == 0 {
291        return None;
292    }
293    let scale = max_w as f32 / ow as f32;
294    let mut nw = max_w;
295    let mut nh = (oh as f32 * scale).round() as usize;
296    if nh == 0 {
297        return None;
298    }
299    if nh > max_h {
300        let hscale = max_h as f32 / nh as f32;
301        nh = max_h;
302        nw = (nw as f32 * hscale).round() as usize;
303        if nw == 0 {
304            return None;
305        }
306    }
307    let resized = img.resize(nw as u32, nh as u32, image::imageops::FilterType::Triangle);
308    let rgba = resized.to_rgba8();
309    let (rw, rh) = (rgba.width() as usize, rgba.height() as usize);
310    Some(DecodedRgba {
311        rgba: rgba.into_raw(),
312        width: rw,
313        height: rh,
314    })
315}
316
317pub fn blit_rgb565_image(
318    buf: &mut [u8],
319    buf_stride: usize,
320    rgb: &[u8],
321    iw: usize,
322    ih: usize,
323    ox: usize,
324    oy: usize,
325    max_w: usize,
326    max_h: usize,
327) {
328    for ry in 0..ih {
329        let py = oy + ry;
330        if py >= max_h {
331            break;
332        }
333        for rx in 0..iw {
334            let px = ox + rx;
335            if px >= max_w {
336                break;
337            }
338            let idx = (ry * iw + rx) * 3;
339            let r = rgb[idx] as u16;
340            let g = rgb[idx + 1] as u16;
341            let b = rgb[idx + 2] as u16;
342            let r5 = (r >> 3) & 0x1f;
343            let g6 = (g >> 2) & 0x3f;
344            let b5 = (b >> 3) & 0x1f;
345            let v = (r5 << 11) | (g6 << 5) | b5;
346            let off = (py * buf_stride + px) * 2;
347            if off + 2 > buf.len() {
348                continue;
349            }
350            buf[off] = (v & 0xff) as u8;
351            buf[off + 1] = (v >> 8) as u8;
352        }
353    }
354}
355
356pub fn blit_rgb565_image_alpha(
357    buf: &mut [u8],
358    buf_stride: usize,
359    rgba: &[u8],
360    iw: usize,
361    ih: usize,
362    ox: usize,
363    oy: usize,
364    max_w: usize,
365    max_h: usize,
366) {
367    for ry in 0..ih {
368        let py = oy + ry;
369        if py >= max_h {
370            break;
371        }
372        for rx in 0..iw {
373            let px = ox + rx;
374            if px >= max_w {
375                break;
376            }
377            let idx = (ry * iw + rx) * 4;
378            let a = rgba[idx + 3];
379            if a == 0 {
380                continue;
381            }
382            let r = rgba[idx] as u16;
383            let g = rgba[idx + 1] as u16;
384            let b = rgba[idx + 2] as u16;
385            let r5 = (r >> 3) & 0x1f;
386            let g6 = (g >> 2) & 0x3f;
387            let b5 = (b >> 3) & 0x1f;
388            let v = (r5 << 11) | (g6 << 5) | b5;
389            let off = (py * buf_stride + px) * 2;
390            if off + 2 > buf.len() {
391                continue;
392            }
393            buf[off] = (v & 0xff) as u8;
394            buf[off + 1] = (v >> 8) as u8;
395        }
396    }
397}
398
399pub fn style_at(runs: &[crate::html_text::StyleRun], off: usize) -> TextStyle {
400    for r in runs {
401        if off >= r.start && off < r.end {
402            return TextStyle {
403                bold: r.bold,
404                italic: r.italic,
405                mono: false,
406            };
407        }
408        if r.start > off {
409            break;
410        }
411    }
412    TextStyle::PLAIN
413}
414
415pub fn style_for(runs: &[crate::html_text::StyleRun], off: usize, base: TextStyle) -> TextStyle {
416    let s = style_at(runs, off);
417    TextStyle {
418        bold: s.bold,
419        italic: s.italic,
420        mono: base.mono,
421    }
422}
423
424#[cfg(test)]
425mod tests;