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