Skip to main content

game_toolkit_gfx/
bitmap_font.rs

1//! Pixel fonts drawn from a glyph sheet through the sprite batcher: whole-pixel positions,
2//! whole-number scales, no anti-aliasing.
3
4use std::collections::HashMap;
5
6use anyhow::{Context as _, Result, bail};
7
8use crate::graphics::Graphics;
9use crate::texture::TextureId;
10
11/// Where one glyph sits in the font's texture and how it is placed.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub struct Glyph {
14    /// Top-left corner of the glyph in the texture, in pixels.
15    pub src_pos: [u32; 2],
16    /// Size of the glyph in the texture, in pixels.
17    pub src_size: [u32; 2],
18    /// Offset from the pen position (top of the line) to the glyph's top-left corner.
19    pub offset: [i32; 2],
20    /// How far the pen moves after this glyph.
21    pub advance: i32,
22}
23
24/// How glyphs are laid out in a sheet for [`BitmapFont::from_grid`].
25#[derive(Clone, Debug)]
26pub struct GridLayout {
27    /// Size of one cell in pixels; glyphs are read left to right, top to bottom.
28    pub cell: [u32; 2],
29    /// The characters in sheet order, one per cell, e.g. every printable ASCII character from
30    /// `' '` to `'~'`. Cells past the end are ignored.
31    pub chars: String,
32    /// `false`: every glyph advances by the cell width (monospaced). `true`: each glyph is
33    /// trimmed to its non-transparent columns and advances by that width plus
34    /// [`GridLayout::spacing`]; fully transparent cells (space) keep half a cell.
35    pub proportional: bool,
36    /// Extra pixels between glyphs in proportional mode.
37    pub spacing: u32,
38}
39
40impl GridLayout {
41    /// Printable ASCII (`' '` to `'~'`) in cells of `width` × `height`, monospaced.
42    pub fn ascii(width: u32, height: u32) -> Self {
43        Self {
44            cell: [width, height],
45            chars: (' '..='~').collect(),
46            proportional: false,
47            spacing: 1,
48        }
49    }
50}
51
52/// A pixel font: glyphs from a sheet texture, drawn at their native size or whole multiples,
53/// on the pixel grid, through the sprite batcher (so text sorts with sprites by layer).
54///
55/// Get one from [`BitmapFont::from_grid`] (a glyph sheet), [`BitmapFont::from_bmfont`] (an
56/// AngelCode BMFont `.fnt` text file plus its page image), or use the built-in 5 × 7 font
57/// through [`Painter::pixel_text`](crate::Painter::pixel_text) /
58/// [`Graphics::builtin_font`]. Draw with [`Painter::bitmap_text`](crate::Painter::bitmap_text).
59#[derive(Clone, Debug)]
60pub struct BitmapFont {
61    texture: TextureId,
62    texture_size: [u32; 2],
63    glyphs: HashMap<char, Glyph>,
64    kerning: HashMap<(char, char), i32>,
65    line_height: u32,
66    fallback: Option<char>,
67}
68
69impl BitmapFont {
70    /// Build a font from glyph metrics over an already-loaded texture.
71    pub fn new(
72        texture: TextureId,
73        texture_size: [u32; 2],
74        line_height: u32,
75        glyphs: HashMap<char, Glyph>,
76    ) -> Self {
77        let fallback = glyphs.contains_key(&'?').then_some('?');
78        Self {
79            texture,
80            texture_size,
81            glyphs,
82            kerning: HashMap::new(),
83            line_height,
84            fallback,
85        }
86    }
87
88    /// Load a glyph sheet (PNG bytes) laid out as a grid. Glyph pixels are drawn with their
89    /// own colour times the tint, so draw the sheet in white to colour text freely.
90    pub fn from_grid(gfx: &mut Graphics, png: &[u8], layout: &GridLayout) -> Result<Self> {
91        let img = image::load_from_memory(png)
92            .context("decode glyph sheet")?
93            .to_rgba8();
94        let (w, h) = img.dimensions();
95        let texture = gfx.create_texture_rgba(w, h, img.as_raw(), Some("bitmap_font"));
96        Ok(Self::from_grid_rgba(texture, [w, h], img.as_raw(), layout))
97    }
98
99    /// [`BitmapFont::from_grid`] over RGBA pixels already uploaded as `texture`.
100    ///
101    /// # Panics
102    ///
103    /// If `rgba` holds fewer than `size[0] * size[1] * 4` bytes.
104    pub fn from_grid_rgba(
105        texture: TextureId,
106        size: [u32; 2],
107        rgba: &[u8],
108        layout: &GridLayout,
109    ) -> Self {
110        let needed = (size[0] as usize)
111            .checked_mul(size[1] as usize)
112            .and_then(|n| n.checked_mul(4))
113            .unwrap_or_else(|| {
114                panic!(
115                    "from_grid_rgba: a {}x{} sheet is too large",
116                    size[0], size[1]
117                )
118            });
119        assert!(
120            rgba.len() >= needed,
121            "from_grid_rgba: {} bytes of RGBA for a {}x{} sheet, need {needed}",
122            rgba.len(),
123            size[0],
124            size[1]
125        );
126        let [cw, ch] = [layout.cell[0].max(1), layout.cell[1].max(1)];
127        let cols = size[0] / cw;
128        let rows = size[1] / ch;
129        // In usize: the size check above guarantees every index fits.
130        let alpha = |x: u32, y: u32| rgba[(y as usize * size[0] as usize + x as usize) * 4 + 3];
131        let mut glyphs = HashMap::new();
132        for (i, c) in layout.chars.chars().enumerate() {
133            let i = i as u32;
134            if i >= cols * rows {
135                break;
136            }
137            let x0 = (i % cols) * cw;
138            let y0 = (i / cols) * ch;
139            let glyph = if layout.proportional {
140                let used: Vec<u32> = (0..cw)
141                    .filter(|&dx| (0..ch).any(|dy| alpha(x0 + dx, y0 + dy) > 0))
142                    .collect();
143                match (used.first(), used.last()) {
144                    (Some(&l), Some(&r)) => Glyph {
145                        src_pos: [x0 + l, y0],
146                        src_size: [r - l + 1, ch],
147                        offset: [0, 0],
148                        advance: (r - l + 1 + layout.spacing) as i32,
149                    },
150                    _ => Glyph {
151                        src_pos: [x0, y0],
152                        src_size: [0, 0],
153                        offset: [0, 0],
154                        advance: (cw / 2).max(1) as i32,
155                    },
156                }
157            } else {
158                Glyph {
159                    src_pos: [x0, y0],
160                    src_size: [cw, ch],
161                    offset: [0, 0],
162                    advance: cw as i32,
163                }
164            };
165            glyphs.insert(c, glyph);
166        }
167        Self::new(texture, size, ch, glyphs)
168    }
169
170    /// Load an AngelCode BMFont in its text format (`.fnt`) with a single page, given the
171    /// page image's PNG bytes. Kerning pairs are applied.
172    pub fn from_bmfont(gfx: &mut Graphics, fnt: &str, page_png: &[u8]) -> Result<Self> {
173        let img = image::load_from_memory(page_png)
174            .context("decode BMFont page")?
175            .to_rgba8();
176        let (w, h) = img.dimensions();
177        let texture = gfx.create_texture_rgba(w, h, img.as_raw(), Some("bmfont"));
178        Self::parse_bmfont(texture, [w, h], fnt)
179    }
180
181    /// The `file` of page 0 named in a BMFont text file, to find its image next to it.
182    pub fn bmfont_page_file(fnt: &str) -> Option<String> {
183        fnt.lines()
184            .filter_map(parse_line)
185            .find(|(tag, kv)| *tag == "page" && kv.get("id").is_none_or(|id| id == "0"))
186            .and_then(|(_, kv)| kv.get("file").cloned())
187    }
188
189    /// Parse BMFont text metrics over an uploaded page texture.
190    pub fn parse_bmfont(texture: TextureId, texture_size: [u32; 2], fnt: &str) -> Result<Self> {
191        let mut line_height = None;
192        let mut glyphs = HashMap::new();
193        let mut kerning = HashMap::new();
194        for (tag, kv) in fnt.lines().filter_map(parse_line) {
195            let num = |k: &str| -> Result<i32> {
196                kv.get(k)
197                    .with_context(|| format!("BMFont {tag}: missing {k}"))?
198                    .parse()
199                    .with_context(|| format!("BMFont {tag}: bad {k}"))
200            };
201            match tag {
202                "common" => {
203                    line_height = Some(num("lineHeight")?.max(1) as u32);
204                    if kv.get("pages").is_some_and(|p| p != "1") {
205                        bail!("BMFont with more than one page is not supported");
206                    }
207                }
208                "char" => {
209                    let Some(c) = char::from_u32(num("id")? as u32) else {
210                        continue;
211                    };
212                    glyphs.insert(
213                        c,
214                        Glyph {
215                            src_pos: [num("x")?.max(0) as u32, num("y")?.max(0) as u32],
216                            src_size: [num("width")?.max(0) as u32, num("height")?.max(0) as u32],
217                            offset: [num("xoffset")?, num("yoffset")?],
218                            advance: num("xadvance")?,
219                        },
220                    );
221                }
222                "kerning" => {
223                    let (Some(a), Some(b)) = (
224                        char::from_u32(num("first")? as u32),
225                        char::from_u32(num("second")? as u32),
226                    ) else {
227                        continue;
228                    };
229                    kerning.insert((a, b), num("amount")?);
230                }
231                _ => {}
232            }
233        }
234        let line_height = line_height.context("BMFont: missing `common` line")?;
235        let mut font = Self::new(texture, texture_size, line_height, glyphs);
236        font.kerning = kerning;
237        Ok(font)
238    }
239
240    /// The built-in 5 × 7 pixel font (6 × 8 cells, printable ASCII, monospaced), uploaded to
241    /// `gfx`. [`Graphics::builtin_font`] keeps one around; use this for a separate copy.
242    pub fn builtin(gfx: &mut Graphics) -> Self {
243        let (size, rgba) = builtin_sheet();
244        let texture = gfx.create_texture_rgba(size[0], size[1], &rgba, Some("builtin_font"));
245        Self::from_grid_rgba(texture, size, &rgba, &GridLayout::ascii(6, 8))
246    }
247
248    /// Replace characters the font lacks with `c` (default `'?'` if the font has it); `None`
249    /// skips them.
250    pub fn set_fallback(&mut self, c: Option<char>) {
251        self.fallback = c;
252    }
253
254    pub fn texture(&self) -> TextureId {
255        self.texture
256    }
257
258    pub fn texture_size(&self) -> [u32; 2] {
259        self.texture_size
260    }
261
262    /// Height of one line at scale 1, in pixels.
263    pub fn line_height(&self) -> u32 {
264        self.line_height
265    }
266
267    pub fn glyph(&self, c: char) -> Option<&Glyph> {
268        self.glyphs.get(&c)
269    }
270
271    /// Size `[width, height]` of `s` drawn at `scale`, in whole pixels: the widest line, and
272    /// the height of all lines (`\n` or `\r\n` starts a new one). The width includes glyphs
273    /// that overhang left of the start position (BMFont glyphs with a negative `xoffset`).
274    /// Saturates instead of overflowing for absurd scales.
275    pub fn measure(&self, s: &str, scale: u32) -> [u32; 2] {
276        let mut width = 0i64;
277        let mut lines = 0u32;
278        for line in text_lines(s) {
279            lines = lines.saturating_add(1);
280            let mut pen = 0i64;
281            let mut left = 0i64;
282            let mut right = 0i64;
283            self.layout_line(line, |g, x| {
284                let x0 = i64::from(x) + i64::from(g.offset[0]);
285                left = left.min(x0);
286                right = right.max(x0 + i64::from(g.src_size[0]));
287                pen = i64::from(x) + i64::from(g.advance);
288            });
289            width = width.max(pen.max(right) - left);
290        }
291        let scale = scale.max(1);
292        let width = u32::try_from(width.max(0)).unwrap_or(u32::MAX);
293        [
294            width.saturating_mul(scale),
295            lines.saturating_mul(self.line_height).saturating_mul(scale),
296        ]
297    }
298
299    /// Call `f(glyph, pen_x)` for each glyph of one line at scale 1.
300    pub(crate) fn layout_line(&self, line: &str, mut f: impl FnMut(&Glyph, i32)) {
301        let mut pen = 0;
302        let mut prev: Option<char> = None;
303        for c in line.chars() {
304            let Some((c, g)) = self.glyphs.get(&c).map(|g| (c, g)).or_else(|| {
305                self.fallback
306                    .and_then(|f| self.glyphs.get(&f).map(|g| (f, g)))
307            }) else {
308                continue;
309            };
310            if let Some(p) = prev {
311                pen += self.kerning.get(&(p, c)).copied().unwrap_or(0);
312            }
313            f(g, pen);
314            pen += g.advance;
315            prev = Some(c);
316        }
317    }
318}
319
320/// The lines of `s` as text is drawn: split at `\n`, with a trailing `\r` (Windows line
321/// endings) dropped so it doesn't render as a glyph.
322pub(crate) fn text_lines(s: &str) -> impl Iterator<Item = &str> {
323    s.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l))
324}
325
326/// One `key=value` line of a BMFont text file: the tag and its attributes.
327fn parse_line(line: &str) -> Option<(&str, HashMap<String, String>)> {
328    let line = line.trim();
329    let (tag, mut rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
330    if tag.is_empty() {
331        return None;
332    }
333    let mut kv = HashMap::new();
334    loop {
335        rest = rest.trim_start();
336        let Some((key, after)) = rest.split_once('=') else {
337            break;
338        };
339        let (value, next) = if let Some(quoted) = after.strip_prefix('"') {
340            match quoted.split_once('"') {
341                Some((v, n)) => (v, n),
342                None => (quoted, ""),
343            }
344        } else {
345            after.split_once(char::is_whitespace).unwrap_or((after, ""))
346        };
347        kv.insert(key.trim().to_owned(), value.to_owned());
348        rest = next;
349    }
350    Some((tag, kv))
351}
352
353/// 5 × 7 glyphs for `' '..='~'`, one byte per row, top to bottom, bit 4 the leftmost pixel.
354#[rustfmt::skip]
355const BUILTIN_GLYPHS: [[u8; 7]; 95] = [
356    [0x00,0x00,0x00,0x00,0x00,0x00,0x00], // ' '
357    [0x04,0x04,0x04,0x04,0x04,0x00,0x04], // !
358    [0x0A,0x0A,0x00,0x00,0x00,0x00,0x00], // "
359    [0x0A,0x0A,0x1F,0x0A,0x1F,0x0A,0x0A], // #
360    [0x04,0x0F,0x14,0x0E,0x05,0x1E,0x04], // $
361    [0x19,0x19,0x02,0x04,0x08,0x13,0x13], // %
362    [0x08,0x14,0x14,0x08,0x15,0x12,0x0D], // &
363    [0x04,0x04,0x00,0x00,0x00,0x00,0x00], // '
364    [0x02,0x04,0x08,0x08,0x08,0x04,0x02], // (
365    [0x08,0x04,0x02,0x02,0x02,0x04,0x08], // )
366    [0x00,0x04,0x15,0x0E,0x15,0x04,0x00], // *
367    [0x00,0x04,0x04,0x1F,0x04,0x04,0x00], // +
368    [0x00,0x00,0x00,0x00,0x06,0x04,0x08], // ,
369    [0x00,0x00,0x00,0x1F,0x00,0x00,0x00], // -
370    [0x00,0x00,0x00,0x00,0x00,0x06,0x06], // .
371    [0x01,0x01,0x02,0x04,0x08,0x10,0x10], // /
372    [0x0E,0x11,0x13,0x15,0x19,0x11,0x0E], // 0
373    [0x04,0x0C,0x04,0x04,0x04,0x04,0x0E], // 1
374    [0x0E,0x11,0x01,0x06,0x08,0x10,0x1F], // 2
375    [0x1F,0x01,0x02,0x06,0x01,0x11,0x0E], // 3
376    [0x02,0x06,0x0A,0x12,0x1F,0x02,0x02], // 4
377    [0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E], // 5
378    [0x06,0x08,0x10,0x1E,0x11,0x11,0x0E], // 6
379    [0x1F,0x01,0x02,0x04,0x08,0x08,0x08], // 7
380    [0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E], // 8
381    [0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C], // 9
382    [0x00,0x06,0x06,0x00,0x06,0x06,0x00], // :
383    [0x00,0x06,0x06,0x00,0x06,0x04,0x08], // ;
384    [0x02,0x04,0x08,0x10,0x08,0x04,0x02], // <
385    [0x00,0x00,0x1F,0x00,0x1F,0x00,0x00], // =
386    [0x08,0x04,0x02,0x01,0x02,0x04,0x08], // >
387    [0x0E,0x11,0x01,0x02,0x04,0x00,0x04], // ?
388    [0x0E,0x11,0x17,0x15,0x17,0x10,0x0E], // @
389    [0x0E,0x11,0x11,0x1F,0x11,0x11,0x11], // A
390    [0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E], // B
391    [0x0E,0x11,0x10,0x10,0x10,0x11,0x0E], // C
392    [0x1E,0x11,0x11,0x11,0x11,0x11,0x1E], // D
393    [0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F], // E
394    [0x1F,0x10,0x10,0x1E,0x10,0x10,0x10], // F
395    [0x0E,0x11,0x10,0x17,0x11,0x11,0x0F], // G
396    [0x11,0x11,0x11,0x1F,0x11,0x11,0x11], // H
397    [0x0E,0x04,0x04,0x04,0x04,0x04,0x0E], // I
398    [0x07,0x02,0x02,0x02,0x02,0x12,0x0C], // J
399    [0x11,0x12,0x14,0x18,0x14,0x12,0x11], // K
400    [0x10,0x10,0x10,0x10,0x10,0x10,0x1F], // L
401    [0x11,0x1B,0x15,0x15,0x11,0x11,0x11], // M
402    [0x11,0x11,0x19,0x15,0x13,0x11,0x11], // N
403    [0x0E,0x11,0x11,0x11,0x11,0x11,0x0E], // O
404    [0x1E,0x11,0x11,0x1E,0x10,0x10,0x10], // P
405    [0x0E,0x11,0x11,0x11,0x15,0x12,0x0D], // Q
406    [0x1E,0x11,0x11,0x1E,0x14,0x12,0x11], // R
407    [0x0F,0x10,0x10,0x0E,0x01,0x01,0x1E], // S
408    [0x1F,0x04,0x04,0x04,0x04,0x04,0x04], // T
409    [0x11,0x11,0x11,0x11,0x11,0x11,0x0E], // U
410    [0x11,0x11,0x11,0x11,0x11,0x0A,0x04], // V
411    [0x11,0x11,0x11,0x15,0x15,0x15,0x0A], // W
412    [0x11,0x11,0x0A,0x04,0x0A,0x11,0x11], // X
413    [0x11,0x11,0x0A,0x04,0x04,0x04,0x04], // Y
414    [0x1F,0x01,0x02,0x04,0x08,0x10,0x1F], // Z
415    [0x0E,0x08,0x08,0x08,0x08,0x08,0x0E], // [
416    [0x10,0x10,0x08,0x04,0x02,0x01,0x01], // backslash
417    [0x0E,0x02,0x02,0x02,0x02,0x02,0x0E], // ]
418    [0x04,0x0A,0x11,0x00,0x00,0x00,0x00], // ^
419    [0x00,0x00,0x00,0x00,0x00,0x00,0x1F], // _
420    [0x08,0x04,0x00,0x00,0x00,0x00,0x00], // `
421    [0x00,0x00,0x0E,0x01,0x0F,0x11,0x0F], // a
422    [0x10,0x10,0x16,0x19,0x11,0x11,0x1E], // b
423    [0x00,0x00,0x0E,0x10,0x10,0x11,0x0E], // c
424    [0x01,0x01,0x0D,0x13,0x11,0x11,0x0F], // d
425    [0x00,0x00,0x0E,0x11,0x1F,0x10,0x0E], // e
426    [0x06,0x09,0x08,0x1C,0x08,0x08,0x08], // f
427    [0x00,0x0F,0x11,0x11,0x0F,0x01,0x0E], // g
428    [0x10,0x10,0x16,0x19,0x11,0x11,0x11], // h
429    [0x04,0x00,0x0C,0x04,0x04,0x04,0x0E], // i
430    [0x02,0x00,0x06,0x02,0x02,0x12,0x0C], // j
431    [0x10,0x10,0x12,0x14,0x18,0x14,0x12], // k
432    [0x0C,0x04,0x04,0x04,0x04,0x04,0x0E], // l
433    [0x00,0x00,0x1A,0x15,0x15,0x11,0x11], // m
434    [0x00,0x00,0x16,0x19,0x11,0x11,0x11], // n
435    [0x00,0x00,0x0E,0x11,0x11,0x11,0x0E], // o
436    [0x00,0x00,0x1E,0x11,0x1E,0x10,0x10], // p
437    [0x00,0x00,0x0D,0x13,0x0F,0x01,0x01], // q
438    [0x00,0x00,0x16,0x19,0x10,0x10,0x10], // r
439    [0x00,0x00,0x0E,0x10,0x0E,0x01,0x1E], // s
440    [0x08,0x08,0x1C,0x08,0x08,0x09,0x06], // t
441    [0x00,0x00,0x11,0x11,0x11,0x13,0x0D], // u
442    [0x00,0x00,0x11,0x11,0x11,0x0A,0x04], // v
443    [0x00,0x00,0x11,0x11,0x15,0x15,0x0A], // w
444    [0x00,0x00,0x11,0x0A,0x04,0x0A,0x11], // x
445    [0x00,0x00,0x11,0x11,0x0F,0x01,0x0E], // y
446    [0x00,0x00,0x1F,0x02,0x04,0x08,0x1F], // z
447    [0x02,0x04,0x04,0x08,0x04,0x04,0x02], // {
448    [0x04,0x04,0x04,0x04,0x04,0x04,0x04], // |
449    [0x08,0x04,0x04,0x02,0x04,0x04,0x08], // }
450    [0x00,0x00,0x08,0x15,0x02,0x00,0x00], // ~
451];
452
453/// The built-in glyphs as a 96 × 48 RGBA sheet (16 × 6 cells of 6 × 8), white on transparent.
454pub(crate) fn builtin_sheet() -> ([u32; 2], Vec<u8>) {
455    const COLS: u32 = 16;
456    let size = [COLS * 6, 6 * 8];
457    let mut rgba = vec![0u8; (size[0] * size[1] * 4) as usize];
458    for (i, rows) in BUILTIN_GLYPHS.iter().enumerate() {
459        let (cx, cy) = ((i as u32 % COLS) * 6, (i as u32 / COLS) * 8);
460        for (dy, bits) in rows.iter().enumerate() {
461            for dx in 0..5u32 {
462                if bits & (0x10 >> dx) != 0 {
463                    let o = (((cy + dy as u32) * size[0] + cx + dx) * 4) as usize;
464                    rgba[o..o + 4].copy_from_slice(&[255; 4]);
465                }
466            }
467        }
468    }
469    (size, rgba)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn builtin() -> BitmapFont {
477        let (size, rgba) = builtin_sheet();
478        BitmapFont::from_grid_rgba(TextureId(0), size, &rgba, &GridLayout::ascii(6, 8))
479    }
480
481    #[test]
482    fn builtin_measures_whole_pixels() {
483        let f = builtin();
484        assert_eq!(f.line_height(), 8);
485        assert_eq!(f.measure("Hi!", 1), [18, 8]);
486        assert_eq!(f.measure("Hi!", 3), [54, 24]);
487        assert_eq!(f.measure("ab\nabcd", 2), [48, 32]);
488        // Unknown characters fall back to '?'.
489        assert_eq!(f.measure("é", 1), [6, 8]);
490    }
491
492    #[test]
493    fn proportional_grid_trims_glyphs() {
494        let (size, rgba) = builtin_sheet();
495        let layout = GridLayout {
496            proportional: true,
497            ..GridLayout::ascii(6, 8)
498        };
499        let f = BitmapFont::from_grid_rgba(TextureId(0), size, &rgba, &layout);
500        // 'i' is 3 pixels wide (0x0C/0x04/0x0E), plus 1 spacing.
501        assert_eq!(f.glyph('i').map(|g| g.advance), Some(4));
502        assert_eq!(f.glyph('M').map(|g| g.advance), Some(6));
503        assert_eq!(f.glyph(' ').map(|g| g.advance), Some(3));
504    }
505
506    #[test]
507    fn bmfont_text_parses() {
508        let fnt = r#"info face="Tiny" size=8
509common lineHeight=10 base=8 scaleW=64 scaleH=64 pages=1
510page id=0 file="tiny_0.png"
511chars count=2
512char id=65 x=0 y=0 width=5 height=7 xoffset=0 yoffset=1 xadvance=6 page=0
513char id=86 x=6 y=0 width=5 height=7 xoffset=0 yoffset=1 xadvance=6 page=0
514kernings count=1
515kerning first=65 second=86 amount=-1
516"#;
517        assert_eq!(
518            BitmapFont::bmfont_page_file(fnt).as_deref(),
519            Some("tiny_0.png")
520        );
521        let f = BitmapFont::parse_bmfont(TextureId(0), [64, 64], fnt).unwrap();
522        assert_eq!(f.line_height(), 10);
523        assert_eq!(
524            f.glyph('A'),
525            Some(&Glyph {
526                src_pos: [0, 0],
527                src_size: [5, 7],
528                offset: [0, 1],
529                advance: 6
530            })
531        );
532        // Kerning pulls V one pixel closer.
533        assert_eq!(f.measure("AV", 1), [11, 10]);
534    }
535
536    #[test]
537    fn measure_counts_left_overhang_and_crlf() {
538        let fnt = "common lineHeight=8 pages=1
539char id=106 x=0 y=0 width=4 height=8 xoffset=-2 yoffset=0 xadvance=3
540";
541        let f = BitmapFont::parse_bmfont(TextureId(0), [16, 16], fnt).unwrap();
542        // 'j' starts 2 px left of the pen and ends at 2: 4 px wide, pen ends at 3 -> 5.
543        assert_eq!(f.measure("j", 1), [5, 8]);
544        let b = builtin();
545        assert_eq!(b.measure("ab\r\ncd", 1), b.measure("ab\ncd", 1));
546        assert_eq!(b.measure("W", u32::MAX)[0], u32::MAX);
547    }
548
549    #[test]
550    #[should_panic(expected = "need")]
551    fn short_rgba_is_rejected() {
552        let _ = BitmapFont::from_grid_rgba(TextureId(0), [6, 8], &[0; 4], &GridLayout::ascii(6, 8));
553    }
554}