Skip to main content

yog_cli/design/
font.rs

1//! Minecraft's text renderer, reproduced from its font definitions: the
2//! `font/*.json` provider lists (`reference`, `space`, `bitmap`, `unihex`,
3//! `ttf`)
4//! resolved through [`Resources`], glyph metrics computed the way the game
5//! computes them (bitmap advance = trimmed width × scale + 1, unifont
6//! advance = width / 2 + 1, ...), and `§` formatting codes. Text drawn
7//! through this matches the game pixel for pixel.
8
9use std::collections::HashMap;
10use std::io::Read;
11use std::sync::{Arc, Mutex, OnceLock};
12
13use serde_json::Value;
14
15use super::assets::{Image, Resources, decode_png};
16
17/// One glyph's metrics and (unless it's blank) its bitmap.
18pub struct Glyph {
19    /// Pen advance in GUI pixels.
20    pub advance: f32,
21    pub shadow_offset: f32,
22    pub bold_offset: f32,
23    pub bitmap: Option<GlyphBitmap>,
24}
25
26/// RGBA texels; drawn `w / oversample` × `h / oversample` GUI pixels big,
27/// `top` GUI pixels below the text's y.
28pub struct GlyphBitmap {
29    pub w: u32,
30    pub h: u32,
31    pub rgba: Vec<u8>,
32    pub oversample: f32,
33    pub top: f32,
34}
35
36struct BitmapProvider {
37    image: Arc<Image>,
38    cell_w: u32,
39    cell_h: u32,
40    scale: f32,
41    ascent: i32,
42    chars: HashMap<char, (u32, u32)>,
43}
44
45struct UnihexProvider {
46    /// Rows left-aligned in a u32 (bit 31 = leftmost pixel) + bit width.
47    glyphs: Arc<HashMap<u32, ([u32; 16], u32)>>,
48    overrides: Vec<(u32, u32, i32, i32)>,
49}
50
51/// A TrueType font (`"type": "ttf"`): glyphs rasterized at `size` ×
52/// `oversample` pixels, drawn `size` GUI pixels high.
53struct TtfProvider {
54    font: fontdue::Font,
55    size: f32,
56    oversample: f32,
57    shift: (f32, f32),
58    skip: Vec<char>,
59}
60
61enum Provider {
62    Space(HashMap<char, f32>),
63    Bitmap(BitmapProvider),
64    Unihex(Arc<UnihexProvider>),
65    Ttf(Arc<TtfProvider>),
66}
67
68pub struct Font {
69    providers: Vec<Provider>,
70    cache: Mutex<HashMap<char, Arc<Glyph>>>,
71}
72
73/// JSON the way Minecraft reads it (Gson, lenient): trailing commas, as
74/// in the game's own `font/include/unifont.json`, are fine.
75fn lenient_json(bytes: &[u8]) -> Option<Value> {
76    if let Ok(v) = serde_json::from_slice(bytes) {
77        return Some(v);
78    }
79    let text = std::str::from_utf8(bytes).ok()?;
80    let mut out = String::with_capacity(text.len());
81    let (mut in_str, mut escaped) = (false, false);
82    let chars: Vec<char> = text.chars().collect();
83    for (i, &c) in chars.iter().enumerate() {
84        if in_str {
85            out.push(c);
86            if escaped {
87                escaped = false;
88            } else if c == '\\' {
89                escaped = true;
90            } else if c == '"' {
91                in_str = false;
92            }
93            continue;
94        }
95        if c == '"' {
96            in_str = true;
97        } else if c == ','
98            && chars[i + 1..]
99                .iter()
100                .find(|c| !c.is_whitespace())
101                .is_some_and(|&n| n == ']' || n == '}')
102        {
103            continue;
104        }
105        out.push(c);
106    }
107    serde_json::from_str(&out).ok()
108}
109
110fn json(res: &Resources, ns: &str, path: &str) -> Vec<Value> {
111    let mut out = Vec::new();
112    if let Some(v) = &res.vanilla {
113        out.extend(
114            v.read_all(&format!("{ns}/{path}"))
115                .iter()
116                .filter_map(|b| lenient_json(b)),
117        );
118    }
119    out
120}
121
122/// A provider's `filter` passes with every font option off (no forced
123/// unicode font, no Japanese glyph variants) — the game's defaults.
124fn filter_passes(p: &Value) -> bool {
125    p.get("filter")
126        .and_then(Value::as_object)
127        .is_none_or(|f| f.values().all(|v| v.as_bool() == Some(false)))
128}
129
130/// Unifont `.hex` files from a zip, cached per resource id (the file is big).
131fn unihex(res: &Resources, file: &str, overrides: &[Value]) -> Option<Arc<UnihexProvider>> {
132    type Cache = Mutex<HashMap<String, Arc<HashMap<u32, ([u32; 16], u32)>>>>;
133    static CACHE: OnceLock<Cache> = OnceLock::new();
134    let cache = CACHE.get_or_init(Default::default);
135    let glyphs = if let Some(hit) = cache.lock().ok()?.get(file).cloned() {
136        hit
137    } else {
138        let (ns, path) = yog_ids::split(file);
139        let bytes = res.read(ns, path)?;
140        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
141        let mut glyphs = HashMap::new();
142        for i in 0..zip.len() {
143            let Ok(mut entry) = zip.by_index(i) else {
144                continue;
145            };
146            if !entry.name().is_ok_and(|n| n.ends_with(".hex")) {
147                continue;
148            }
149            let mut text = String::new();
150            if entry.read_to_string(&mut text).is_err() {
151                continue;
152            }
153            for line in text.lines() {
154                let Some((cp, data)) = line.split_once(':') else {
155                    continue;
156                };
157                let Ok(cp) = u32::from_str_radix(cp.trim(), 16) else {
158                    continue;
159                };
160                let data = data.trim();
161                let row_digits = data.len() / 16;
162                if !matches!(row_digits, 2 | 4 | 6 | 8) || data.len() % 16 != 0 {
163                    continue;
164                }
165                let mut rows = [0u32; 16];
166                let mut ok = true;
167                for (r, row) in rows.iter_mut().enumerate() {
168                    match u32::from_str_radix(&data[r * row_digits..(r + 1) * row_digits], 16) {
169                        Ok(v) => *row = v << (32 - row_digits * 4),
170                        Err(_) => ok = false,
171                    }
172                }
173                if ok {
174                    glyphs.insert(cp, (rows, (row_digits * 4) as u32));
175                }
176            }
177        }
178        let glyphs = Arc::new(glyphs);
179        if let Ok(mut c) = cache.lock() {
180            c.insert(file.to_string(), glyphs.clone());
181        }
182        glyphs
183    };
184    let overrides = overrides
185        .iter()
186        .filter_map(|o| {
187            let ch = |k: &str| o.get(k)?.as_str()?.chars().next().map(|c| c as u32);
188            Some((
189                ch("from")?,
190                ch("to")?,
191                o.get("left")?.as_i64()? as i32,
192                o.get("right")?.as_i64()? as i32,
193            ))
194        })
195        .collect();
196    Some(Arc::new(UnihexProvider { glyphs, overrides }))
197}
198
199fn load_providers(res: &Resources, id: &str, depth: usize, out: &mut Vec<Provider>) {
200    if depth > 8 {
201        return;
202    }
203    let (ns, path) = yog_ids::split(id);
204    for def in json(res, ns, &format!("font/{path}.json")) {
205        let Some(list) = def.get("providers").and_then(Value::as_array) else {
206            continue;
207        };
208        for p in list {
209            if !filter_passes(p) {
210                continue;
211            }
212            match p.get("type").and_then(Value::as_str) {
213                Some("reference") => {
214                    if let Some(id) = p.get("id").and_then(Value::as_str) {
215                        load_providers(res, id, depth + 1, out);
216                    }
217                }
218                Some("space") => {
219                    let advances = p
220                        .get("advances")
221                        .and_then(Value::as_object)
222                        .map(|a| {
223                            a.iter()
224                                .filter_map(|(k, v)| Some((k.chars().next()?, v.as_f64()? as f32)))
225                                .collect()
226                        })
227                        .unwrap_or_default();
228                    out.push(Provider::Space(advances));
229                }
230                Some("bitmap") => {
231                    if let Some(b) = bitmap(res, p) {
232                        out.push(Provider::Bitmap(b));
233                    }
234                }
235                Some("unihex") => {
236                    let file = p
237                        .get("hex_file")
238                        .and_then(Value::as_str)
239                        .unwrap_or_default();
240                    let overrides = p
241                        .get("size_overrides")
242                        .and_then(Value::as_array)
243                        .cloned()
244                        .unwrap_or_default();
245                    if let Some(u) = unihex(res, file, &overrides) {
246                        out.push(Provider::Unihex(u));
247                    }
248                }
249                Some("ttf") => {
250                    if let Some(t) = ttf(res, p) {
251                        out.push(Provider::Ttf(Arc::new(t)));
252                    }
253                }
254                _ => {} // legacy_unicode: not reproduced — the next provider (or the missing glyph) answers
255            }
256        }
257    }
258}
259
260/// A TrueType provider's size when it gives none, and a bitmap one's height
261/// (the game's defaults); where the default font's baseline is, from a
262/// line's top.
263const TTF_SIZE: f64 = 11.0;
264const BITMAP_HEIGHT: f64 = 8.0;
265const BASELINE: f32 = 7.0;
266
267fn ttf(res: &Resources, p: &Value) -> Option<TtfProvider> {
268    let file = p.get("file")?.as_str()?;
269    let (ns, path) = yog_ids::split(file);
270    let bytes = res.read(ns, &format!("font/{path}"))?;
271    let size = p.get("size").and_then(Value::as_f64).unwrap_or(TTF_SIZE) as f32;
272    let oversample = p.get("oversample").and_then(Value::as_f64).unwrap_or(1.0).max(0.1) as f32;
273    let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings { scale: size * oversample, ..Default::default() }).ok()?;
274    let shift = p
275        .get("shift")
276        .and_then(Value::as_array)
277        .map(|a| (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32))
278        .unwrap_or((0.0, 0.0));
279    let skip = match p.get("skip") {
280        Some(Value::String(s)) => s.chars().collect(),
281        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).flat_map(str::chars).collect(),
282        _ => Vec::new(),
283    };
284    Some(TtfProvider { font, size, oversample, shift, skip })
285}
286
287impl TtfProvider {
288    fn glyph(&self, ch: char) -> Option<Glyph> {
289        if self.skip.contains(&ch) || self.font.lookup_glyph_index(ch) == 0 {
290            return None;
291        }
292        let px = self.size * self.oversample;
293        let (m, coverage) = self.font.rasterize(ch, px);
294        let advance = m.advance_width / self.oversample;
295        if m.width == 0 || m.height == 0 {
296            return Some(Glyph { advance, shadow_offset: 1.0, bold_offset: 1.0, bitmap: None });
297        }
298        // Left bearing as blank columns, so the bitmap starts at the pen.
299        let pad = (m.xmin.max(0) as f32 + self.shift.0 * self.oversample).round().max(0.0) as usize;
300        let w = m.width + pad;
301        let mut rgba = vec![0u8; w * m.height * 4];
302        for y in 0..m.height {
303            for x in 0..m.width {
304                let a = coverage[y * m.width + x];
305                let i = (y * w + x + pad) * 4;
306                rgba[i..i + 4].copy_from_slice(&[255, 255, 255, a]);
307            }
308        }
309        // The baseline sits where the default font's does.
310        let top = BASELINE - (m.ymin as f32 + m.height as f32) / self.oversample + self.shift.1;
311        Some(Glyph {
312            advance,
313            shadow_offset: 1.0,
314            bold_offset: 1.0,
315            bitmap: Some(GlyphBitmap { w: w as u32, h: m.height as u32, rgba, oversample: self.oversample, top }),
316        })
317    }
318}
319
320fn bitmap(res: &Resources, p: &Value) -> Option<BitmapProvider> {
321    let file = p.get("file")?.as_str()?;
322    let (ns, path) = yog_ids::split(file);
323    let image = Arc::new(decode_png(&res.read(ns, &format!("{}{path}", yog_ids::res::TEXTURES))?)?);
324    let rows: Vec<Vec<char>> = p
325        .get("chars")?
326        .as_array()?
327        .iter()
328        .filter_map(|r| Some(r.as_str()?.chars().collect()))
329        .collect();
330    let cols = rows.first()?.len() as u32;
331    if cols == 0 || rows.is_empty() {
332        return None;
333    }
334    let cell_w = image.width / cols;
335    let cell_h = image.height / rows.len() as u32;
336    let height = p.get("height").and_then(Value::as_f64).unwrap_or(BITMAP_HEIGHT) as f32;
337    let mut chars = HashMap::new();
338    for (r, row) in rows.iter().enumerate() {
339        for (c, &ch) in row.iter().enumerate() {
340            if ch != '\0' {
341                chars.entry(ch).or_insert((c as u32, r as u32));
342            }
343        }
344    }
345    Some(BitmapProvider {
346        image,
347        cell_w,
348        cell_h,
349        scale: height / cell_h as f32,
350        ascent: p.get("ascent").and_then(Value::as_i64).unwrap_or(7) as i32,
351        chars,
352    })
353}
354
355impl BitmapProvider {
356    fn glyph(&self, ch: char) -> Option<Glyph> {
357        let &(col, row) = self.chars.get(&ch)?;
358        let (x0, y0) = (col * self.cell_w, row * self.cell_h);
359        // Trimmed width: the rightmost column with any visible texel.
360        let actual = (0..self.cell_w)
361            .rev()
362            .find(|&x| (0..self.cell_h).any(|y| self.image.pixel(x0 + x, y0 + y).a() != 0))
363            .map_or(0, |x| x + 1);
364        let mut rgba = Vec::with_capacity((self.cell_w * self.cell_h * 4) as usize);
365        for y in 0..self.cell_h {
366            for x in 0..self.cell_w {
367                rgba.extend_from_slice(&self.image.pixel(x0 + x, y0 + y).to_rgba8());
368            }
369        }
370        Some(Glyph {
371            advance: (0.5 + actual as f32 * self.scale).floor() + 1.0,
372            shadow_offset: 1.0,
373            bold_offset: 1.0,
374            bitmap: Some(GlyphBitmap {
375                w: self.cell_w,
376                h: self.cell_h,
377                rgba,
378                oversample: 1.0 / self.scale,
379                top: (7 - self.ascent) as f32,
380            }),
381        })
382    }
383}
384
385impl UnihexProvider {
386    fn glyph(&self, ch: char) -> Option<Glyph> {
387        let cp = ch as u32;
388        let (rows, bit_width) = self.glyphs.get(&cp)?;
389        let (left, right) = match self.overrides.iter().find(|o| (o.0..=o.1).contains(&cp)) {
390            Some(&(_, _, l, r)) => (l, r),
391            None => {
392                let all = rows.iter().fold(0u32, |a, r| a | r);
393                if all == 0 {
394                    (0, *bit_width as i32 - 1)
395                } else {
396                    (all.leading_zeros() as i32, 31 - all.trailing_zeros() as i32)
397                }
398            }
399        };
400        let width = (right - left + 1).max(0) as u32;
401        let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
402        for row in rows {
403            for x in 0..width {
404                let bit = left + x as i32;
405                let on = (0..32).contains(&bit) && row & (1u32 << (31 - bit)) != 0;
406                rgba.extend_from_slice(if on { &[0xFF; 4] } else { &[0; 4] });
407            }
408        }
409        Some(Glyph {
410            advance: (width / 2 + 1) as f32,
411            shadow_offset: 0.5,
412            bold_offset: 0.5,
413            bitmap: (width > 0).then_some(GlyphBitmap {
414                w: width,
415                h: 16,
416                rgba,
417                oversample: 2.0,
418                top: 0.0,
419            }),
420        })
421    }
422}
423
424/// The game's "missing" glyph: a 5×8 box outline, advance 6.
425fn missing_glyph() -> Glyph {
426    let mut rgba = Vec::with_capacity(5 * 8 * 4);
427    for y in 0..8 {
428        for x in 0..5 {
429            let edge = x == 0 || x == 4 || y == 0 || y == 7;
430            rgba.extend_from_slice(if edge { &[0xFF; 4] } else { &[0; 4] });
431        }
432    }
433    Glyph {
434        advance: yog_ui::text::CHAR_W,
435        shadow_offset: 1.0,
436        bold_offset: 1.0,
437        bitmap: Some(GlyphBitmap {
438            w: 5,
439            h: 8,
440            rgba,
441            oversample: 1.0,
442            top: 0.0,
443        }),
444    }
445}
446
447impl Font {
448    /// `font_id` like `minecraft:default`; `""` is the default font.
449    pub fn load(res: &Resources, font_id: &str) -> Arc<Font> {
450        type Cache = Mutex<HashMap<(Option<std::path::PathBuf>, String), Arc<Font>>>;
451        static CACHE: OnceLock<Cache> = OnceLock::new();
452        let id = if font_id.trim().is_empty() {
453            yog_ids::game::DEFAULT_FONT
454        } else {
455            font_id.trim()
456        };
457        let key = (res.mod_root.clone(), id.to_string());
458        let cache = CACHE.get_or_init(Default::default);
459        if let Some(f) = cache.lock().ok().and_then(|c| c.get(&key).cloned()) {
460            return f;
461        }
462        let mut providers = Vec::new();
463        load_providers(res, id, 0, &mut providers);
464        if providers.is_empty() && id != yog_ids::game::DEFAULT_FONT {
465            // An unknown font falls back to the default one, as in game.
466            load_providers(res, yog_ids::game::DEFAULT_FONT, 0, &mut providers);
467        }
468        let font = Arc::new(Font {
469            providers,
470            cache: Mutex::new(HashMap::new()),
471        });
472        if let Ok(mut c) = cache.lock() {
473            c.insert(key, font.clone());
474        }
475        font
476    }
477
478    /// Whether any glyph source was found (else everything is the missing box).
479    pub fn is_real(&self) -> bool {
480        !self.providers.is_empty()
481    }
482
483    pub fn glyph(&self, ch: char) -> Arc<Glyph> {
484        if let Some(g) = self.cache.lock().ok().and_then(|c| c.get(&ch).cloned()) {
485            return g;
486        }
487        let g = Arc::new(
488            self.providers
489                .iter()
490                .find_map(|p| match p {
491                    Provider::Space(a) => a.get(&ch).map(|&advance| Glyph {
492                        advance,
493                        shadow_offset: 1.0,
494                        bold_offset: 1.0,
495                        bitmap: None,
496                    }),
497                    Provider::Bitmap(b) => b.glyph(ch),
498                    Provider::Unihex(u) => u.glyph(ch),
499                    Provider::Ttf(t) => t.glyph(ch),
500                })
501                .unwrap_or_else(missing_glyph),
502        );
503        if let Ok(mut c) = self.cache.lock() {
504            c.insert(ch, g.clone());
505        }
506        g
507    }
508
509    /// Width in GUI pixels, as the game's `Font.width` measures it (`§` codes skipped, bold +offset).
510    pub fn width(&self, text: &str) -> f32 {
511        styled(text, yog_gfx::Color::WHITE.0)
512            .iter()
513            .map(|s| {
514                self.glyph(s.ch).advance
515                    + if s.bold {
516                        self.glyph(s.ch).bold_offset
517                    } else {
518                        0.0
519                    }
520            })
521            .sum()
522    }
523}
524
525/// One character of a string after `§` formatting codes.
526#[derive(Clone, Copy)]
527pub struct Styled {
528    pub ch: char,
529    pub color: u32,
530    pub bold: bool,
531    pub underline: bool,
532    pub strike: bool,
533}
534
535/// A legacy chat color's code: `5` dark purple, `a` green.
536pub fn chat_color(code: char) -> Option<u32> {
537    code.to_digit(CODE_RADIX).map(|i| yog_gfx::Color::rgb(CODES[i as usize]).0)
538}
539
540/// Chat color codes are hex digits.
541const CODE_RADIX: u32 = 16;
542
543/// Legacy chat colors `§0`–`§f`.
544const CODES: [u32; 16] = [
545    0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA, 0x555555,
546    0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
547];
548
549/// `text` split into characters with the style `§` codes give them —
550/// `StringDecomposer.iterateFormatted`.
551/// Color `rgb` with `color`'s alpha.
552fn with_alpha_of(color: u32, rgb: u32) -> u32 {
553    yog_gfx::Color::rgb(rgb).with_alpha(yog_gfx::Color(color).a()).0
554}
555
556pub fn styled(text: &str, color: u32) -> Vec<Styled> {
557    // Yog rich text spans first (`{b|…}`, `{#rrggbb|…}`), then the game's
558    // own `§` codes inside each run.
559    let mut out = Vec::new();
560    for (run, st) in yog_ui::rich::runs(text) {
561        let base = Styled {
562            ch: ' ',
563            color: st.color.map_or(color, |rgb| with_alpha_of(color, rgb)),
564            bold: st.bold,
565            underline: st.underline,
566            strike: st.strike,
567        };
568        let mut cur = base;
569        let mut it = run.chars();
570        while let Some(c) = it.next() {
571            if c == '§' {
572                let Some(code) = it.next() else { break };
573                let code = code.to_ascii_lowercase();
574                if let Some(i) = code.to_digit(16) {
575                    cur = Styled {
576                        color: with_alpha_of(color, CODES[i as usize]),
577                        ..base
578                    };
579                } else {
580                    match code {
581                        'l' => cur.bold = true,
582                        'n' => cur.underline = true,
583                        'm' => cur.strike = true,
584                        'r' => cur = base,
585                        _ => {} // italic / obfuscated: drawn plain
586                    }
587                }
588                continue;
589            }
590            out.push(Styled { ch: c, ..cur });
591        }
592    }
593    out
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn json_is_read_leniently_like_gson() {
602        let v = lenient_json(br#"{"a": [1, 2,], "s": "x,]",}"#).unwrap();
603        assert_eq!(v["a"][1], 2);
604        assert_eq!(v["s"], "x,]");
605    }
606
607    #[test]
608    fn styled_text_follows_rich_spans() {
609        let s = styled("a{b #102030|b}c", 0xFF_FFFFFF);
610        assert_eq!(s.iter().map(|c| c.ch).collect::<String>(), "abc");
611        assert!(s[1].bold && s[1].color == 0xFF_102030 && !s[2].bold);
612    }
613
614    #[test]
615    fn styled_text_follows_section_codes() {
616        let s = styled("a§cb§lc§rd", 0xFF_FFFFFF);
617        assert_eq!(s.iter().map(|c| c.ch).collect::<String>(), "abcd");
618        assert_eq!(s[1].color, 0xFF_FF5555);
619        assert!(s[2].bold && !s[3].bold);
620        assert_eq!(s[3].color, 0xFF_FFFFFF);
621    }
622
623    #[test]
624    fn without_game_files_text_measures_with_the_missing_glyph() {
625        let f = Font::load(
626            &Resources {
627                mod_root: None,
628                vanilla: None,
629            },
630            yog_ids::game::DEFAULT_FONT,
631        );
632        assert!(!f.is_real());
633        assert_eq!(f.width("ab"), 12.0);
634    }
635}