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