Skip to main content

shuyi_cli/
font_atlas.rs

1//! `font-atlas [--no-text] [--wide [<set>=]<wide.bdf>]... <font.bdf>... <out-dir>`: turns a BDF
2//! bitmap font into a
3//! font atlas (`atlas.png` + `atlas.ron`, read as
4//! [`shuyi_assets::font::FontAtlasDef`]) and its one-bit copy for a one-bit
5//! screen (`atlas_1bit.bin`). It keeps the glyphs the font has of
6//! [`ATLAS_RANGES`], of the characters the game's text uses and of the
7//! glyphs the project requires ([`AtlasChars`]): when `shuyi.ron` names a
8//! game tool program, `font-atlas` runs in it and reads the game's text
9//! (`--no-text`: the ranges and the required glyphs only). Further BDF
10//! files add glyphs the first lacks (a game's own, e.g. controller button
11//! shapes). The atlas must have the glyphs the project requires
12//! ([`crate::project::FontAtlas::required_glyphs`]); a character the text
13//! uses that the fonts lack is named, and `check` fails on it.
14//!
15//! `--wide` adds a wide font (Japanese, Chinese), its cells twice as wide
16//! as the narrow font's: the atlas keeps its glyphs of the characters the
17//! text uses that the narrow fonts lack, and of the project's
18//! `wide_glyphs`, each over two cells after the narrow glyphs. A BDF font
19//! in JIS X 0208 (`CHARSET_REGISTRY "JISX0208..."`) is read as Unicode,
20//! with the crate's `jis` feature (without it, such a font is refused).
21//!
22//! `--wide <set>=<wide.bdf>` makes a named wide set instead, and may be
23//! given once per set ([`WideFont`]): the set keeps the wide characters of
24//! the languages that name it (a pack's `lang.ron`'s `wide_font`, the
25//! source's `SourceLang::wide_font`), their wide punctuation if they have
26//! it wide, and the project's
27//! `wide_set_glyphs` for it. An unnamed `--wide` keeps those of the
28//! languages that name no set. The sets are laid out one after another,
29//! in the order given, after the narrow glyphs.
30
31use std::collections::{BTreeMap, BTreeSet};
32use std::fs;
33use std::path::{Path, PathBuf};
34
35use serde::Serialize;
36use shuyi_assets::font::{
37    AtlasRect, CellWidths, FontAtlasDef, WideSet, describe, is_combining_mark, is_wide_punctuation,
38};
39use shuyi_assets::image::Image;
40use shuyi_assets::lang::Lang;
41
42use crate::codec::encode_png;
43use crate::project::FontAtlas;
44
45/// Unicode ranges copied into the atlas (whatever the font has of them).
46pub const ATLAS_RANGES: &[(char, char)] = &[
47    (' ', '~'),               // ASCII
48    ('\u{a0}', '\u{ff}'),     // Latin-1 supplement
49    ('\u{2c7}', '\u{2c7}'),   // ˇ caron
50    ('\u{391}', '\u{3c9}'),   // Greek (CP437 has several)
51    ('\u{2010}', '\u{206f}'), // General punctuation (• † …)
52    ('\u{2190}', '\u{21ff}'), // Arrows
53    ('\u{2200}', '\u{22ff}'), // Mathematical operators (≈ ≤ ≥ ∞)
54    ('\u{2300}', '\u{23ff}'), // Miscellaneous technical (⌂ ⌐)
55    ('\u{2500}', '\u{25ff}'), // Box drawing, blocks, geometric shapes
56    ('\u{2600}', '\u{26ff}'), // Miscellaneous symbols (☺ ♣ ♪)
57    ('\u{2715}', '\u{2715}'), // ✕ (a controller's cross button)
58];
59
60/// Atlas cells per row.
61pub const COLUMNS: u32 = 32;
62
63/// The characters an atlas keeps, of those its font has: every one of
64/// [`ATLAS_RANGES`], every character the game's text uses, and the glyphs
65/// the project requires; never a combining mark or a control character.
66/// Its wide sets keep the characters of the languages that draw from them
67/// ([`wants_wide`](Self::wants_wide)). [`AtlasChars::default`] is the
68/// ranges alone.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct AtlasChars {
71    /// The characters the text uses.
72    used: BTreeSet<char>,
73    /// The glyphs the project requires.
74    required: BTreeSet<char>,
75    /// The characters the text uses, by the wide set its language draws
76    /// from (`None`: it names none).
77    used_by_set: BTreeMap<Option<String>, BTreeSet<char>>,
78    /// The punctuation the text uses that its language has wide
79    /// ([`is_wide_punctuation`]), by the wide set it draws from.
80    punctuation_by_set: BTreeMap<Option<String>, BTreeSet<char>>,
81    /// The wide glyphs the project names for each set, whatever the text
82    /// uses (`None`: the unnamed set).
83    wide: BTreeMap<Option<String>, BTreeSet<char>>,
84}
85
86/// Whether an atlas may keep `c` at all: it isn't a control character or a
87/// combining mark.
88fn keepable(c: char) -> bool {
89    !c.is_control() && !is_combining_mark(c)
90}
91
92impl AtlasChars {
93    /// The ranges, every character of `lang`'s text ([`Lang::texts`]) and
94    /// the glyphs of `required`.
95    pub fn of_lang(lang: &Lang, required: &str) -> Self {
96        let mut chars = Self {
97            required: required.chars().filter(|&c| keepable(c)).collect(),
98            ..Self::default()
99        };
100        let source = &lang.source_lang().code;
101        for (at, text) in lang.texts() {
102            // Only the language's set and punctuation matter here, not the
103            // atlas's own wide glyphs.
104            let widths = lang.widths(at.lang.unwrap_or(source), &CellWidths::NARROW);
105            let set = widths.wide_font().map(str::to_owned);
106            let punctuation = widths.wide_punctuation();
107            for c in text.chars().filter(|&c| keepable(c)) {
108                chars.used.insert(c);
109                chars.used_by_set.entry(set.clone()).or_default().insert(c);
110                if punctuation && is_wide_punctuation(c) {
111                    let by_set = chars.punctuation_by_set.entry(set.clone());
112                    by_set.or_default().insert(c);
113                }
114            }
115        }
116        chars
117    }
118
119    /// The same characters, and the glyphs of `wide` kept from the
120    /// unnamed wide font whatever the text uses (a name entry's letters).
121    #[must_use]
122    pub fn with_wide(self, wide: &str) -> Self {
123        self.with_wide_set(None, wide)
124    }
125
126    /// The same characters, and the glyphs of `wide` kept from the wide
127    /// font of set `set` (`None`: the unnamed one) whatever the text uses.
128    #[must_use]
129    pub fn with_wide_set(mut self, set: Option<&str>, wide: &str) -> Self {
130        let wide = wide.chars().filter(|&c| keepable(c)).collect();
131        self.wide.insert(set.map(str::to_owned), wide);
132        self
133    }
134
135    /// Whether the atlas keeps `c` in the wide set `set` (`None`: the
136    /// unnamed one), if its font has it: a character the text of the
137    /// languages drawing from that set uses, or one of the project's wide
138    /// glyphs for it, that the narrow fonts (`narrow`) don't have; or
139    /// punctuation those languages have wide.
140    pub fn wants_wide(&self, c: char, narrow: &BitmapFont, set: Option<&str>) -> bool {
141        let of = |by_set: &BTreeMap<Option<String>, BTreeSet<char>>| {
142            by_set
143                .get(&set.map(str::to_owned))
144                .is_some_and(|chars| chars.contains(&c))
145        };
146        keepable(c)
147            && (of(&self.punctuation_by_set)
148                || (!(narrow.glyphs.contains_key(&c) && self.wants(c))
149                    && (of(&self.used_by_set) || of(&self.wide))))
150    }
151
152    /// Whether the atlas keeps `c`, if its font has it.
153    pub fn wants(&self, c: char) -> bool {
154        keepable(c)
155            && (ATLAS_RANGES.iter().any(|(lo, hi)| (lo..=hi).contains(&&c))
156                || self.used.contains(&c)
157                || self.required.contains(&c))
158    }
159
160    /// The characters the text uses, without control characters and
161    /// combining marks.
162    pub fn used(&self) -> &BTreeSet<char> {
163        &self.used
164    }
165}
166
167/// How many of the characters the text uses that the fonts lack
168/// [`run`]'s summary lists by name.
169const LACKED_SHOWN: usize = 20;
170
171/// The one-bit atlas's file name, next to `atlas.png`.
172pub const ONE_BIT_FILE: &str = "atlas_1bit.bin";
173
174/// A bitmap font: fixed cell size, each glyph an on/off pixel grid placed
175/// inside the cell (row-major, `cell_w × cell_h`).
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct BitmapFont {
178    /// Cell width in pixels.
179    pub cell_w: u32,
180    /// Cell height in pixels.
181    pub cell_h: u32,
182    /// Glyph pixels by character.
183    pub glyphs: BTreeMap<char, Vec<bool>>,
184}
185
186impl BitmapFont {
187    /// Adds `extra`'s glyphs. Fails if its cell size differs or it has a
188    /// glyph this font already has: extra files add glyphs, they never
189    /// change the font's own.
190    pub fn add(&mut self, extra: Self) -> Result<(), String> {
191        if (extra.cell_w, extra.cell_h) != (self.cell_w, self.cell_h) {
192            return Err(format!(
193                "cell size {}×{}, expected {}×{}",
194                extra.cell_w, extra.cell_h, self.cell_w, self.cell_h
195            ));
196        }
197        for (c, pixels) in extra.glyphs {
198            if self.glyphs.insert(c, pixels).is_some() {
199                return Err(format!("U+{:04X} is already in the font", u32::from(c)));
200            }
201        }
202        Ok(())
203    }
204}
205
206/// Numbered, trimmed lines of a BDF file.
207type Lines<'a> = dyn Iterator<Item = (usize, &'a str)> + 'a;
208
209/// The font bounding box: width, height, x offset, y offset.
210type BoundingBox = [i64; 4];
211
212/// What a BDF font's `ENCODING` numbers are.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214enum Encoding {
215    /// Unicode code points (`ISO10646`, and any registry but JIS's).
216    Unicode,
217    /// JIS X 0208 codes, a row and a cell of 0x21 to 0x7E each.
218    Jis,
219}
220
221impl Encoding {
222    /// The encoding a `CHARSET_REGISTRY` value (quoted or not) names.
223    fn of_registry(registry: &str) -> Self {
224        let registry = registry.trim_matches('"').to_ascii_uppercase();
225        if registry.starts_with("JISX0208") {
226            Self::Jis
227        } else {
228            Self::Unicode
229        }
230    }
231
232    /// The character of `ENCODING` number `code`, if it has one.
233    fn char_of(self, code: i64) -> Option<char> {
234        match self {
235            Self::Unicode => u32::try_from(code).ok().and_then(char::from_u32),
236            Self::Jis => jis_char(code),
237        }
238    }
239
240    /// Whether this build reads fonts of this encoding: JIS X 0208's
241    /// needs the `jis` feature.
242    const fn read_here(self) -> bool {
243        matches!(self, Self::Unicode) || cfg!(feature = "jis")
244    }
245}
246
247/// Why a JIS font isn't read without the `jis` feature.
248const NO_JIS: &str = "the font is in JIS X 0208 (CHARSET_REGISTRY JISX0208): turn on \
249                      shuyi-cli's `jis` feature for the game's tools, or convert the font to \
250                      Unicode";
251
252/// The Unicode character of JIS X 0208 code `code` (row in the high byte,
253/// cell in the low), read as its EUC-JP bytes (both bytes with their high
254/// bit set), or `None` if it has none. Without the `jis` feature, none
255/// ([`parse_bdf`] refuses such a font first).
256#[cfg(not(feature = "jis"))]
257fn jis_char(_: i64) -> Option<char> {
258    None
259}
260
261/// The Unicode character of JIS X 0208 code `code` (row in the high byte,
262/// cell in the low), read as its EUC-JP bytes (both bytes with their high
263/// bit set), or `None` if it has none.
264#[cfg(feature = "jis")]
265fn jis_char(code: i64) -> Option<char> {
266    let [_, _, _, _, _, _, row, cell] = code.to_be_bytes();
267    let inside = |b: u8| (0x21..=0x7e).contains(&b);
268    if !(inside(row) && inside(cell)) || code >> 16 != 0 {
269        return None;
270    }
271    let bytes = [row | 0x80, cell | 0x80];
272    let (text, malformed) = encoding_rs::EUC_JP.decode_without_bom_handling(&bytes);
273    let mut chars = text.chars();
274    match (chars.next(), chars.next()) {
275        (Some(c), None) if !malformed => Some(c),
276        _ => None,
277    }
278}
279
280/// Parses a BDF font. Glyph bitmaps are positioned in the cell using the
281/// font and glyph bounding boxes; pixels outside the cell are dropped. A
282/// font whose `CHARSET_REGISTRY` is JIS X 0208's has its codes read as
283/// Unicode (with the `jis` feature; refused without it); a code with no
284/// Unicode character is left out.
285pub fn parse_bdf(text: &str) -> Result<BitmapFont, String> {
286    let mut lines = text.lines().enumerate().map(|(i, l)| (i + 1, l.trim()));
287    let mut font_box: Option<BoundingBox> = None;
288    let mut encoding = Encoding::Unicode;
289    let mut glyphs = BTreeMap::new();
290    while let Some((n, line)) = lines.next() {
291        let mut words = line.split_whitespace();
292        match words.next() {
293            Some("FONTBOUNDINGBOX") => font_box = Some(numbers(words, n)?),
294            Some("CHARSET_REGISTRY") => {
295                encoding = Encoding::of_registry(words.next().unwrap_or_default());
296                if !encoding.read_here() {
297                    return Err(format!("line {n}: {NO_JIS}"));
298                }
299            }
300            Some("STARTCHAR") => {
301                let font_box =
302                    font_box.ok_or(format!("line {n}: STARTCHAR before FONTBOUNDINGBOX"))?;
303                if let (Some(c), pixels) = parse_char(&mut lines, font_box, encoding)? {
304                    glyphs.insert(c, pixels);
305                }
306            }
307            _ => {}
308        }
309    }
310    let [w, h, _, _] = font_box.ok_or("no FONTBOUNDINGBOX")?;
311    let size = |v: i64| u32::try_from(v).ok().filter(|&v| v > 0);
312    let (Some(cell_w), Some(cell_h)) = (size(w), size(h)) else {
313        return Err(format!("bad FONTBOUNDINGBOX size {w}×{h}"));
314    };
315    Ok(BitmapFont {
316        cell_w,
317        cell_h,
318        glyphs,
319    })
320}
321
322/// Parses one glyph, from after `STARTCHAR` through `ENDCHAR`. The char is
323/// `None` for glyphs without a Unicode character in `encoding`.
324fn parse_char(
325    lines: &mut Lines<'_>,
326    font_box: BoundingBox,
327    font_encoding: Encoding,
328) -> Result<(Option<char>, Vec<bool>), String> {
329    let [cell_w, cell_h, _, _] = font_box;
330    let mut encoding = None;
331    let mut glyph_box = None;
332    let mut pixels = vec![false; to_usize(cell_w * cell_h)];
333    while let Some((n, line)) = lines.next() {
334        let mut words = line.split_whitespace();
335        match words.next() {
336            Some("ENCODING") => {
337                let [e] = numbers(words, n)?;
338                encoding = font_encoding.char_of(e);
339            }
340            Some("BBX") => glyph_box = Some(numbers(words, n)?),
341            Some("BITMAP") => {
342                let glyph_box = glyph_box.ok_or(format!("line {n}: BITMAP before BBX"))?;
343                parse_bitmap(lines, n, font_box, glyph_box, &mut pixels)?;
344            }
345            Some("ENDCHAR") => break,
346            _ => {}
347        }
348    }
349    Ok((encoding, pixels))
350}
351
352/// Reads a glyph's hex bitmap rows (after `BITMAP` on line `start`) and sets
353/// the matching cell pixels.
354fn parse_bitmap(
355    lines: &mut Lines<'_>,
356    start: usize,
357    [cell_w, cell_h, font_x, font_y]: BoundingBox,
358    [width, height, glyph_x, glyph_y]: BoundingBox,
359    pixels: &mut [bool],
360) -> Result<(), String> {
361    // Rows from the cell top to the glyph top: cell ascent minus glyph top.
362    let top = (cell_h + font_y) - (glyph_y + height);
363    for row in 0..height {
364        let (n, hex) = lines
365            .next()
366            .ok_or(format!("line {start}: bitmap ends early"))?;
367        let bits =
368            u128::from_str_radix(hex, 16).map_err(|e| format!("line {n}: bad bitmap row: {e}"))?;
369        let row_bits = i64::try_from(hex.len() * 4).unwrap_or(0);
370        for col in 0..width.min(row_bits) {
371            let (x, y) = (glyph_x - font_x + col, top + row);
372            let on = (bits >> (row_bits - 1 - col)) & 1 == 1;
373            if on && (0..cell_w).contains(&x) && (0..cell_h).contains(&y) {
374                pixels[to_usize(y * cell_w + x)] = true;
375            }
376        }
377    }
378    Ok(())
379}
380
381/// Parses exactly `N` integers from `words`.
382fn numbers<'a, const N: usize>(
383    words: impl Iterator<Item = &'a str>,
384    line: usize,
385) -> Result<[i64; N], String> {
386    let v: Vec<i64> = words
387        .map(str::parse)
388        .collect::<Result<_, _>>()
389        .map_err(|e| format!("line {line}: {e}"))?;
390    v.try_into()
391        .map_err(|v: Vec<i64>| format!("line {line}: expected {N} numbers, got {}", v.len()))
392}
393
394/// Non-negative `i64` → `usize` (callers only pass in-range values).
395fn to_usize(v: i64) -> usize {
396    usize::try_from(v).unwrap_or(0)
397}
398
399/// The atlas: the glyph map, plus its picture (white where the glyph is
400/// set, transparent elsewhere).
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Atlas {
403    /// Layout and glyph map.
404    pub def: FontAtlasDef,
405    /// The picture.
406    pub image: Image,
407}
408
409/// Lays out every glyph of `font` that `chars` wants, in codepoint order,
410/// [`COLUMNS`] per row.
411pub fn build_atlas(font: &BitmapFont, chars: &AtlasChars) -> Atlas {
412    build_wide_atlas(font, None, chars)
413}
414
415/// [`build_atlas`], and after the narrow glyphs the glyphs of `wide` that
416/// `chars` wants wide ([`AtlasChars::wants_wide`]), in codepoint order,
417/// each over two cells of one row: the unnamed wide set.
418/// [`build_atlas_with_sets`] says more.
419pub fn build_wide_atlas(font: &BitmapFont, wide: Option<&BitmapFont>, chars: &AtlasChars) -> Atlas {
420    let wide: Vec<(Option<&str>, &BitmapFont)> =
421        wide.map(|wide| (None, wide)).into_iter().collect();
422    build_atlas_with_sets(font, &wide, chars)
423}
424
425/// [`build_atlas`], and after the narrow glyphs each wide set of `wides`
426/// (`None`: the unnamed one) in turn: the glyphs of its font that `chars`
427/// wants in it ([`AtlasChars::wants_wide`]), in codepoint order, each over
428/// two cells of one row. A wide font's cells must be twice as wide as
429/// `font`'s and as tall ([`check_wide`]).
430pub fn build_atlas_with_sets(
431    font: &BitmapFont,
432    wides: &[(Option<&str>, &BitmapFont)],
433    chars: &AtlasChars,
434) -> Atlas {
435    let chosen: Vec<(&char, &Vec<bool>)> = font
436        .glyphs
437        .iter()
438        .filter(|&(&c, _)| chars.wants(c))
439        .collect();
440    let mut next = u32::try_from(chosen.len()).unwrap_or(u32::MAX);
441    let mut def = FontAtlasDef {
442        cell_w: font.cell_w,
443        cell_h: font.cell_h,
444        columns: COLUMNS,
445        glyphs: chosen.iter().map(|&(&c, _)| c).zip(0..).collect(),
446        ..FontAtlasDef::default()
447    };
448    let mut placed_wide: Vec<(u32, &Vec<bool>)> = Vec::new();
449    for &(set, wide) in wides {
450        let mut cells = WideSet::new();
451        let wanted = wide
452            .glyphs
453            .iter()
454            .filter(|&(&c, _)| chars.wants_wide(c, font, set));
455        for (&c, pixels) in wanted {
456            if next % COLUMNS == COLUMNS - 1 {
457                next += 1; // Never split across a row's end.
458            }
459            cells.insert(c, next);
460            placed_wide.push((next, pixels));
461            next += 2;
462        }
463        match set {
464            None => def.wide = cells,
465            Some(name) => {
466                def.wide_sets.insert(name.to_owned(), cells);
467            }
468        }
469    }
470    let rows = next.div_ceil(COLUMNS).max(1);
471    let (width, height) = (COLUMNS * font.cell_w, rows * font.cell_h);
472    let mut rgba = vec![0u8; to_usize(i64::from(width) * i64::from(height) * 4)];
473    let wide_rect = |index: u32| {
474        let rect = def.cell_rect(index);
475        AtlasRect {
476            w: rect.w * 2,
477            ..rect
478        }
479    };
480    let placed = chosen
481        .iter()
482        .map(|&(&c, pixels)| (def.glyph_rect(c), pixels))
483        .chain(
484            placed_wide
485                .iter()
486                .map(|&(i, pixels)| (Some(wide_rect(i)), pixels)),
487        );
488    for (rect, pixels) in placed {
489        let Some(rect) = rect else {
490            continue;
491        };
492        for (i, _) in pixels.iter().enumerate().filter(|&(_, &on)| on) {
493            let i = u32::try_from(i).unwrap_or(0);
494            let (x, y) = (rect.x + i % rect.w, rect.y + i / rect.w);
495            let at = to_usize((i64::from(y) * i64::from(width) + i64::from(x)) * 4);
496            rgba[at..at + 4].copy_from_slice(&[255; 4]);
497        }
498    }
499    Atlas {
500        def,
501        image: Image {
502            width,
503            height,
504            rgba,
505        },
506    }
507}
508
509/// Whether `wide` fits beside `font` as its wide font: its cells twice as
510/// wide and as tall.
511pub fn check_wide(font: &BitmapFont, wide: &BitmapFont) -> Result<(), String> {
512    let want = (font.cell_w * 2, font.cell_h);
513    if (wide.cell_w, wide.cell_h) == want {
514        Ok(())
515    } else {
516        Err(format!(
517            "a wide font's cells are twice as wide as the font's: {}×{}, not {}×{}",
518            want.0, want.1, wide.cell_w, wide.cell_h
519        ))
520    }
521}
522
523/// The one-bit atlas ([`ONE_BIT_FILE`]) of the atlas
524/// picture `image` laid out as `def`: each glyph in
525/// cell index order, one byte per pixel row, the highest bit the left
526/// pixel, set where the picture isn't see-through; a wide glyph's two
527/// halves are two cells like any other. Fails for cells wider than 8
528/// pixels.
529pub fn one_bit(def: &FontAtlasDef, image: &Image) -> Result<Vec<u8>, String> {
530    if def.cell_w > 8 {
531        return Err(format!(
532            "the one-bit atlas holds cells up to 8 pixels wide, not {}",
533            def.cell_w
534        ));
535    }
536    let count = def.cell_end();
537    let mut out = Vec::with_capacity(to_usize(i64::from(count) * i64::from(def.cell_h)));
538    for index in 0..count {
539        let cell = def.cell_rect(index);
540        for y in cell.y..cell.y + cell.h {
541            let mut row = 0u8;
542            for x in cell.x..cell.x + cell.w {
543                let alpha =
544                    to_usize((i64::from(y) * i64::from(image.width) + i64::from(x)) * 4 + 3);
545                let lit = image.rgba.get(alpha).is_some_and(|&a| a > 0);
546                row |= u8::from(lit) << (7 - (x - cell.x));
547            }
548            out.push(row);
549        }
550    }
551    Ok(out)
552}
553
554/// What `atlas.ron` holds: a [`FontAtlasDef`], without its `wide` or its
555/// `wide_sets` when there are none, so an atlas without them is written as
556/// before.
557#[derive(Serialize)]
558struct AtlasRonBody<'a> {
559    cell_w: u32,
560    cell_h: u32,
561    columns: u32,
562    glyphs: &'a BTreeMap<char, u32>,
563    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
564    wide: &'a WideSet,
565    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
566    wide_sets: &'a BTreeMap<String, WideSet>,
567}
568
569/// The `atlas.ron` text for `def`, with a generated-file header naming
570/// the tool as `command` and pointing at `readme`, if there is one.
571pub fn atlas_ron(
572    def: &FontAtlasDef,
573    source_name: &str,
574    command: &str,
575    readme: Option<&str>,
576) -> Result<String, String> {
577    // LF on every OS, so regenerating never churns line endings.
578    let config = ron::ser::PrettyConfig::default().new_line("\n");
579    let body = AtlasRonBody {
580        cell_w: def.cell_w,
581        cell_h: def.cell_h,
582        columns: def.columns,
583        glyphs: &def.glyphs,
584        wide: &def.wide,
585        wide_sets: &def.wide_sets,
586    };
587    let body = ron::ser::to_string_pretty(&body, config).map_err(|e| e.to_string())?;
588    let first = format!("// Generated by `{command} font-atlas` from {source_name}. Do not edit");
589    let maps = "Maps each glyph to its cell in atlas.png.";
590    Ok(match readme {
591        Some(readme) => format!("{first};\n// see {readme}. {maps}\n{body}\n"),
592        None => format!("{first}.\n// {maps}\n{body}\n"),
593    })
594}
595
596/// Reads and parses the BDF font at `path`.
597pub fn read_font(path: &Path) -> Result<BitmapFont, String> {
598    let text = fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
599    parse_bdf(&text).map_err(|e| format!("{}: {e}", path.display()))
600}
601
602/// A wide font `font-atlas` reads: the set it makes and its BDF file.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct WideFont {
605    /// The wide set's name, the atlas's own word that a pack's `wide_font`
606    /// names; `None` for the unnamed set.
607    pub set: Option<String>,
608    /// The BDF file.
609    pub path: PathBuf,
610}
611
612impl WideFont {
613    /// The wide font `--wide` names: `<set>=<font.bdf>`, or `<font.bdf>`
614    /// for the unnamed set. A set's name holds no `/` or `\`, so a path
615    /// with an `=` in a folder's name is still a path.
616    pub fn parse(arg: &str) -> Self {
617        match arg.split_once('=') {
618            Some((set, path)) if !set.is_empty() && !set.contains(['/', '\\']) => Self {
619                set: Some(set.to_owned()),
620                path: PathBuf::from(path),
621            },
622            _ => Self {
623                set: None,
624                path: PathBuf::from(arg),
625            },
626        }
627    }
628}
629
630/// The line of [`run`]'s summary naming the characters the text uses that
631/// `font` and the wide fonts `wides` lack, the first [`LACKED_SHOWN`] by
632/// name; `None` if they have them all.
633fn lacked(font: &BitmapFont, wides: &[BitmapFont], chars: &AtlasChars) -> Option<String> {
634    let lacked: Vec<char> = chars
635        .used()
636        .iter()
637        .copied()
638        .filter(|c| !font.glyphs.contains_key(c))
639        .filter(|c| !wides.iter().any(|wide| wide.glyphs.contains_key(c)))
640        .collect();
641    if lacked.is_empty() {
642        return None;
643    }
644    let mut named: Vec<String> = lacked
645        .iter()
646        .take(LACKED_SHOWN)
647        .map(|&c| describe(c))
648        .collect();
649    if lacked.len() > LACKED_SHOWN {
650        named.push(format!("and {} more", lacked.len() - LACKED_SHOWN));
651    }
652    Some(format!(
653        "font-atlas: the fonts lack {} character(s) the text uses: {}",
654        lacked.len(),
655        named.join(", ")
656    ))
657}
658
659/// Runs the command: reads the fonts at `font_paths` (the first is the
660/// font; the rest add glyphs to it) and the wide fonts `wides`, each its
661/// own set (one set once), and writes the glyphs `chars` wants to
662/// `atlas.png`, `atlas.ron` and `atlas_1bit.bin` in `out_dir`. Fails if
663/// the result lacks a glyph `rules` require; a character the text uses
664/// that the fonts lack is named in the summary, not a failure. `command`
665/// is what `atlas.ron`'s header calls the tool.
666pub fn run(
667    rules: &FontAtlas,
668    command: &str,
669    font_paths: &[PathBuf],
670    wides: &[WideFont],
671    out_dir: &Path,
672    chars: &AtlasChars,
673) -> Result<String, String> {
674    let (font_path, extras) = font_paths.split_first().ok_or("no font given")?;
675    let mut font = read_font(font_path)?;
676    for path in extras {
677        let extra = read_font(path)?;
678        font.add(extra)
679            .map_err(|e| format!("{}: {e}", path.display()))?;
680    }
681    let mut sets = BTreeSet::new();
682    let mut wide_fonts = Vec::new();
683    for wide in wides {
684        if !sets.insert(wide.set.as_deref()) {
685            return Err(match &wide.set {
686                Some(set) => format!("two wide fonts for the set {set:?}"),
687                None => "two wide fonts for the unnamed set".to_owned(),
688            });
689        }
690        let path = &wide.path;
691        let read = read_font(path)?;
692        check_wide(&font, &read).map_err(|e| format!("{}: {e}", path.display()))?;
693        wide_fonts.push(read);
694    }
695    let by_set: Vec<(Option<&str>, &BitmapFont)> = wides
696        .iter()
697        .map(|wide| wide.set.as_deref())
698        .zip(&wide_fonts)
699        .collect();
700    let atlas = build_atlas_with_sets(&font, &by_set, chars);
701    let problems = atlas.def.problems(&rules.required_glyphs);
702    if !problems.is_empty() {
703        return Err(format!(
704            "{}: unusable atlas:\n  {}",
705            font_path.display(),
706            problems.join("\n  ")
707        ));
708    }
709    let names: Vec<String> = font_paths
710        .iter()
711        .chain(wides.iter().map(|wide| &wide.path))
712        .filter_map(|p| p.file_name())
713        .map(|n| n.to_string_lossy().into_owned())
714        .collect();
715    let ron = atlas_ron(
716        &atlas.def,
717        &names.join(" + "),
718        command,
719        rules.readme.as_deref(),
720    )?;
721    let png = encode_png(atlas.image.width, atlas.image.height, &atlas.image.rgba)?;
722    let bits = one_bit(&atlas.def, &atlas.image)?;
723    fs::create_dir_all(out_dir).map_err(|e| format!("creating {}: {e}", out_dir.display()))?;
724    for (file, bytes) in [
725        ("atlas.png", png.as_slice()),
726        ("atlas.ron", ron.as_bytes()),
727        (ONE_BIT_FILE, bits.as_slice()),
728    ] {
729        let path = out_dir.join(file);
730        fs::write(&path, bytes).map_err(|e| format!("writing {}: {e}", path.display()))?;
731    }
732    let wide_count = wide_count(&atlas.def, wides);
733    let mut summary = format!(
734        "font-atlas: {} glyphs{wide_count}, {}×{} px → {}",
735        atlas.def.glyphs.len(),
736        atlas.image.width,
737        atlas.image.height,
738        out_dir.display()
739    );
740    if let Some(lacked) = lacked(&font, &wide_fonts, chars) {
741        summary.push('\n');
742        summary.push_str(&lacked);
743    }
744    Ok(summary)
745}
746
747/// What [`run`]'s summary says of the wide glyphs: ` and 3 wide` for the
748/// unnamed set alone, ` and wide sets ja 2, zh-Hans 3` with named ones,
749/// nothing without a wide font.
750fn wide_count(def: &FontAtlasDef, wides: &[WideFont]) -> String {
751    let count = |set: Option<&str>| def.wide_set(set).map_or(0, BTreeMap::len);
752    match wides {
753        [] => String::new(),
754        [WideFont { set: None, .. }] => format!(" and {} wide", def.wide.len()),
755        _ => {
756            let sets: Vec<String> = wides
757                .iter()
758                .map(|wide| {
759                    let name = wide.set.as_deref().unwrap_or("unnamed");
760                    format!("{name} {}", count(wide.set.as_deref()))
761                })
762                .collect();
763            format!(" and wide sets {}", sets.join(", "))
764        }
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::codec::decode_png;
772
773    /// What a project requires: the fallback glyph and two letters.
774    fn rules() -> FontAtlas {
775        FontAtlas {
776            required_glyphs: "?Ab".to_owned(),
777            ..FontAtlas::default()
778        }
779    }
780
781    /// A 4×4 font (descent 1): 'A' is a 2×2 box at the baseline's left,
782    /// 'b' a single pixel with an x offset, one unencoded glyph.
783    const TINY: &str = "STARTFONT 2.1\n\
784        FONTBOUNDINGBOX 4 4 0 -1\n\
785        CHARS 3\n\
786        STARTCHAR A\nENCODING 65\nBBX 2 2 0 0\nBITMAP\nC0\n80\nENDCHAR\n\
787        STARTCHAR b\nENCODING 98\nBBX 1 1 2 -1\nBITMAP\n80\nENDCHAR\n\
788        STARTCHAR none\nENCODING -1\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n\
789        ENDFONT\n";
790
791    fn grid(font: &BitmapFont, c: char) -> Vec<String> {
792        let px = &font.glyphs[&c];
793        px.chunks(to_usize(i64::from(font.cell_w)))
794            .map(|row| row.iter().map(|&on| if on { '#' } else { '.' }).collect())
795            .collect()
796    }
797
798    #[test]
799    fn parses_and_positions_glyphs() {
800        let font = parse_bdf(TINY).unwrap();
801        assert_eq!((font.cell_w, font.cell_h), (4, 4));
802        assert_eq!(font.glyphs.keys().collect::<String>(), "Ab");
803        assert_eq!(grid(&font, 'A'), ["....", "##..", "#...", "...."]);
804        assert_eq!(grid(&font, 'b'), ["....", "....", "....", "..#."]);
805    }
806
807    #[test]
808    fn font_x_offset_shifts_glyphs() {
809        // The font box starts 1 px left of the origin, so a glyph at x = 0
810        // lands in cell column 1.
811        let bdf = "FONTBOUNDINGBOX 4 2 -1 0\n\
812            STARTCHAR a\nENCODING 97\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n";
813        let font = parse_bdf(bdf).unwrap();
814        assert_eq!(grid(&font, 'a'), ["....", ".#.."]);
815    }
816
817    #[test]
818    fn parse_errors() {
819        assert!(
820            parse_bdf("STARTCHAR A\n")
821                .unwrap_err()
822                .contains("before FONTBOUNDINGBOX")
823        );
824        assert_eq!(parse_bdf("").unwrap_err(), "no FONTBOUNDINGBOX");
825        assert!(
826            parse_bdf("FONTBOUNDINGBOX 0 4 0 0\n")
827                .unwrap_err()
828                .contains("bad FONTBOUNDINGBOX")
829        );
830        assert!(
831            parse_bdf("FONTBOUNDINGBOX 4 4 0\n")
832                .unwrap_err()
833                .contains("expected 4 numbers, got 3")
834        );
835        assert!(
836            parse_bdf("FONTBOUNDINGBOX 4 x 0 0\n")
837                .unwrap_err()
838                .starts_with("line 1: ")
839        );
840        let no_bbx = "FONTBOUNDINGBOX 4 4 0 0\nSTARTCHAR A\nBITMAP\n";
841        assert!(
842            parse_bdf(no_bbx)
843                .unwrap_err()
844                .contains("line 3: BITMAP before BBX")
845        );
846        let short = "FONTBOUNDINGBOX 4 4 0 0\nSTARTCHAR A\nBBX 1 2 0 0\nBITMAP\n80\n";
847        assert!(parse_bdf(short).unwrap_err().contains("bitmap ends early"));
848        let bad = "FONTBOUNDINGBOX 4 4 0 0\nSTARTCHAR A\nBBX 1 1 0 0\nBITMAP\nzz\n";
849        assert!(parse_bdf(bad).unwrap_err().contains("bad bitmap row"));
850    }
851
852    #[test]
853    fn atlas_layout_and_pixels() {
854        let atlas = build_atlas(&parse_bdf(TINY).unwrap(), &AtlasChars::default());
855        assert_eq!(atlas.def.columns, COLUMNS);
856        assert_eq!(atlas.def.glyphs, [('A', 0), ('b', 1)].into());
857        assert_eq!((atlas.image.width, atlas.image.height), (COLUMNS * 4, 4));
858        let lit = |x: u32, y: u32| {
859            atlas.image.rgba[to_usize(i64::from((y * atlas.image.width + x) * 4 + 3))] == 255
860        };
861        assert!(lit(0, 1) && lit(1, 1) && lit(0, 2));
862        assert!(!lit(1, 2) && !lit(0, 0));
863        assert!(lit(4 + 2, 3));
864        let lit_count = atlas
865            .image
866            .rgba
867            .chunks(4)
868            .filter(|p| p == &[255; 4])
869            .count();
870        assert_eq!(lit_count, 4);
871        assert!(
872            atlas
873                .image
874                .rgba
875                .chunks(4)
876                .all(|p| p == [255; 4] || p == [0; 4])
877        );
878    }
879
880    #[test]
881    fn ron_round_trips_with_header() {
882        let def = build_atlas(&parse_bdf(TINY).unwrap(), &AtlasChars::default()).def;
883        let text = atlas_ron(&def, "tiny.bdf", "shuyi", None).unwrap();
884        assert!(text.starts_with(
885            "// Generated by `shuyi font-atlas` from tiny.bdf. Do not edit.\n\
886             // Maps each glyph to its cell in atlas.png.\n("
887        ));
888        assert!(!text.contains("\r\n"), "LF only");
889        assert_eq!(ron::from_str::<FontAtlasDef>(&text).unwrap(), def);
890        let text = atlas_ron(&def, "a + b", "tools", Some("fonts/README.md")).unwrap();
891        assert!(text.starts_with(
892            "// Generated by `tools font-atlas` from a + b. Do not edit;\n\
893             // see fonts/README.md. Maps each glyph to its cell in atlas.png.\n("
894        ));
895        assert_eq!(ron::from_str::<FontAtlasDef>(&text).unwrap(), def);
896    }
897
898    #[test]
899    fn run_rejects_fonts_missing_required_glyphs() {
900        let dir = std::env::temp_dir().join(format!("shuyi-font-atlas-{}", std::process::id()));
901        fs::create_dir_all(&dir).unwrap();
902        let bdf = dir.join("tiny.bdf");
903        fs::write(&bdf, TINY).unwrap();
904        let run = |fonts: &[PathBuf], out: &Path| {
905            run(&rules(), "shuyi", fonts, &[], out, &AtlasChars::default())
906        };
907        let err = run(std::slice::from_ref(&bdf), &dir.join("out")).unwrap_err();
908        assert!(err.contains("missing required glyphs"), "{err}");
909        assert!(!dir.join("out").exists());
910        assert!(
911            run(&[dir.join("missing.bdf")], &dir)
912                .unwrap_err()
913                .starts_with("reading ")
914        );
915        // An extra font that can't be read or doesn't fit is named.
916        let err = run(&[bdf.clone(), dir.join("missing.bdf")], &dir).unwrap_err();
917        assert!(err.starts_with("reading ") && err.contains("missing.bdf"));
918        let err = run(&[bdf.clone(), bdf], &dir).unwrap_err();
919        assert!(err.contains("tiny.bdf: U+0041 is already in the font"));
920        assert_eq!(run(&[], &dir).unwrap_err(), "no font given");
921        fs::remove_dir_all(&dir).unwrap();
922    }
923
924    #[test]
925    fn run_writes_the_atlas_its_one_bit_copy_and_the_ron() {
926        let dir = std::env::temp_dir().join(format!("shuyi-font-run-{}", std::process::id()));
927        fs::create_dir_all(&dir).unwrap();
928        let bdf = dir.join("tiny.bdf");
929        let question = "STARTCHAR q\nENCODING 63\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n";
930        fs::write(&bdf, TINY.replace("ENDFONT", question)).unwrap();
931        let out = dir.join("out");
932        let said = run(
933            &rules(),
934            "shuyi",
935            std::slice::from_ref(&bdf),
936            &[],
937            &out,
938            &AtlasChars::default(),
939        )
940        .unwrap();
941        assert_eq!(
942            said,
943            format!("font-atlas: 3 glyphs, 128×4 px → {}", out.display())
944        );
945        let (w, h, _) = decode_png(&fs::read(out.join("atlas.png")).unwrap()).unwrap();
946        assert_eq!((w, h), (128, 4));
947        let ron = fs::read_to_string(out.join("atlas.ron")).unwrap();
948        assert!(ron.starts_with("// Generated by `shuyi font-atlas` from tiny.bdf."));
949        assert_eq!(fs::read(out.join(ONE_BIT_FILE)).unwrap().len(), 3 * 4);
950        fs::remove_dir_all(&dir).unwrap();
951    }
952
953    #[test]
954    fn extra_fonts_add_glyphs_and_never_replace_one() {
955        let extra = |body: &str| parse_bdf(&format!("FONTBOUNDINGBOX 4 4 0 -1\n{body}")).unwrap();
956        let mut font = parse_bdf(TINY).unwrap();
957        let c = "STARTCHAR c\nENCODING 99\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n";
958        font.add(extra(c)).unwrap();
959        assert_eq!(font.glyphs.keys().collect::<String>(), "Abc");
960        assert_eq!(grid(&font, 'c'), ["....", "....", "#...", "...."]);
961        assert_eq!(grid(&font, 'A'), ["....", "##..", "#...", "...."]);
962        // The same glyph again, or another cell size: refused.
963        assert_eq!(
964            font.add(extra(c)).unwrap_err(),
965            "U+0063 is already in the font"
966        );
967        let wide = parse_bdf("FONTBOUNDINGBOX 5 4 0 -1\n").unwrap();
968        assert_eq!(font.add(wide).unwrap_err(), "cell size 5×4, expected 4×4");
969        let tall = parse_bdf("FONTBOUNDINGBOX 4 5 0 -1\n").unwrap();
970        assert_eq!(font.add(tall).unwrap_err(), "cell size 4×5, expected 4×4");
971    }
972
973    /// `TINY` with `?`, and `Ж`, `ł` and a combining acute (U+0301), none
974    /// of them in the ranges.
975    fn wider() -> String {
976        let glyph = |name: &str, code: u32| {
977            format!("STARTCHAR {name}\nENCODING {code}\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n")
978        };
979        let extra = [
980            ("q", 63),
981            ("zhe", 0x416),
982            ("lstroke", 0x142),
983            ("acute", 0x301),
984        ]
985        .map(|(name, code)| glyph(name, code))
986        .concat();
987        TINY.replace("ENDFONT", &extra)
988    }
989
990    /// English screen text `menu.title` written `text`.
991    fn text(text: &str) -> Lang {
992        let english = [("menu.title".to_owned(), text.to_owned())];
993        Lang::new(english.into(), BTreeMap::new())
994    }
995
996    #[test]
997    fn the_atlas_keeps_the_ranges_the_text_and_the_required_glyphs() {
998        let chars = AtlasChars::of_lang(&text("Жar\u{301}\n"), "Ł");
999        let used: String = chars.used().iter().collect();
1000        assert_eq!(used, "arЖ", "no control character or combining mark");
1001        for c in ['A', 'é', '♣', 'Ж', 'Ł'] {
1002            assert!(chars.wants(c), "{c:?}");
1003        }
1004        for c in ['ł', '\u{301}', '\n', '\u{7}'] {
1005            assert!(!chars.wants(c), "{c:?}");
1006        }
1007        // The ranges alone, as before.
1008        let ranges = AtlasChars::default();
1009        assert!(ranges.used().is_empty());
1010        assert!(ranges.wants('A') && ranges.wants('é') && !ranges.wants('Ж'));
1011        assert!(!AtlasChars::of_lang(&Lang::default(), "\u{301}").wants('\u{301}'));
1012    }
1013
1014    #[test]
1015    fn the_atlas_has_the_texts_glyphs_in_code_point_order() {
1016        let font = parse_bdf(&wider()).unwrap();
1017        let plain = build_atlas(&font, &AtlasChars::default());
1018        assert_eq!(plain.def.drawn().collect::<String>(), "?Ab");
1019        let chars = AtlasChars::of_lang(&text("Ж ł e\u{301}"), "");
1020        let atlas = build_atlas(&font, &chars);
1021        assert_eq!(atlas.def.drawn().collect::<String>(), "?AbłЖ");
1022        // The ranges' glyphs keep their cells; the text's come after.
1023        assert_eq!(atlas.def.glyphs[&'b'], plain.def.glyphs[&'b']);
1024        assert_eq!(atlas.def.glyphs[&'Ж'], 4);
1025    }
1026
1027    #[test]
1028    fn run_keeps_the_texts_characters_and_names_those_the_fonts_lack() {
1029        let dir = std::env::temp_dir().join(format!("shuyi-font-text-{}", std::process::id()));
1030        fs::create_dir_all(&dir).unwrap();
1031        let bdf = dir.join("wider.bdf");
1032        fs::write(&bdf, wider()).unwrap();
1033        let out = dir.join("out");
1034        let chars = AtlasChars::of_lang(&text("ЖłЖЯ∑b\u{301}"), "");
1035        let said = run(
1036            &rules(),
1037            "shuyi",
1038            std::slice::from_ref(&bdf),
1039            &[],
1040            &out,
1041            &chars,
1042        )
1043        .unwrap();
1044        assert_eq!(
1045            said,
1046            format!(
1047                "font-atlas: 5 glyphs, 128×4 px → {}\nfont-atlas: the fonts lack 2 character(s) \
1048                 the text uses: 'Я' (U+042F), '∑' (U+2211)",
1049                out.display()
1050            )
1051        );
1052        let ron = fs::read_to_string(out.join("atlas.ron")).unwrap();
1053        let def: FontAtlasDef = ron::from_str(&ron).unwrap();
1054        assert_eq!(def.drawn().collect::<String>(), "?AbłЖ");
1055        // A text of every character but the few the summary lists.
1056        let many: String = ('\u{4e00}'..'\u{4e19}').collect();
1057        let chars = AtlasChars::of_lang(&text(&many), "");
1058        let said = run(
1059            &rules(),
1060            "shuyi",
1061            std::slice::from_ref(&bdf),
1062            &[],
1063            &out,
1064            &chars,
1065        )
1066        .unwrap();
1067        assert!(said.contains("lack 25 character(s)"), "{said}");
1068        let last = format!("{}, and 5 more", describe('\u{4e13}'));
1069        assert!(said.ends_with(&last), "{said}");
1070        fs::remove_dir_all(&dir).unwrap();
1071    }
1072
1073    #[test]
1074    fn the_templates_atlas_is_what_font_atlas_makes_of_its_bdf() {
1075        let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/shuyi-default/base");
1076        let shuyi_ron = fs::read_to_string(base.join("shuyi.ron")).unwrap();
1077        let config = crate::project::Config::parse(&shuyi_ron).unwrap();
1078        let rules = config.font_atlas.unwrap();
1079        assert_eq!(rules.required_glyphs, shuyi_ui::basic::BASIC_GLYPHS);
1080        let font = read_font(&base.join("files-src/ter-u16n.bdf")).unwrap();
1081        // A new game's text uses nothing outside the ranges: the atlas is
1082        // the ranges' alone.
1083        let atlas = build_atlas(&font, &AtlasChars::default());
1084        let committed = base.join("crates/game/files/font");
1085        let png = fs::read(committed.join("atlas.png")).unwrap();
1086        let (width, height, rgba) = decode_png(&png).unwrap();
1087        assert_eq!((width, height), (atlas.image.width, atlas.image.height));
1088        assert!(
1089            rgba == atlas.image.rgba,
1090            "the template's atlas.png is stale"
1091        );
1092        let command = config.command.as_deref().unwrap_or("shuyi");
1093        let ron = atlas_ron(&atlas.def, "ter-u16n.bdf", command, rules.readme.as_deref()).unwrap();
1094        let written = fs::read_to_string(committed.join("atlas.ron")).unwrap();
1095        assert_eq!(
1096            written.replace("\r\n", "\n"),
1097            ron,
1098            "the template's atlas.ron is stale"
1099        );
1100        let found = atlas.def.problems(&rules.required_glyphs);
1101        assert!(found.is_empty(), "{found:?}");
1102    }
1103
1104    proptest::proptest! {
1105        /// Every character of the text is used, unless it is a control
1106        /// character or a combining mark.
1107        #[test]
1108        fn every_character_of_the_text_is_used_but_controls_and_marks(
1109            texts in proptest::collection::vec("\\PC{0,8}|[a\\n\u{301}é\u{7}]{0,4}", 1..4),
1110        ) {
1111            let english: BTreeMap<String, String> = texts
1112                .iter()
1113                .enumerate()
1114                .map(|(i, text)| (format!("k.{i}"), text.clone()))
1115                .collect();
1116            let chars = AtlasChars::of_lang(&Lang::new(english, BTreeMap::new()), "");
1117            for c in texts.iter().flat_map(|t| t.chars()) {
1118                let kept = !c.is_control() && !is_combining_mark(c);
1119                proptest::prop_assert_eq!(chars.used().contains(&c), kept, "{:?}", c);
1120            }
1121        }
1122    }
1123
1124    /// A wide fixture font of ours: `口`, `田`, `日` and `一`, 16 × 16.
1125    fn wide_fixture(jis: bool) -> BitmapFont {
1126        let name = if jis {
1127            "wide-16-jis.bdf"
1128        } else {
1129            "wide-16.bdf"
1130        };
1131        read_font(&fixtures().join(name)).unwrap()
1132    }
1133
1134    /// The unnamed wide font at `path`.
1135    fn unnamed(path: &Path) -> WideFont {
1136        WideFont {
1137            set: None,
1138            path: path.to_owned(),
1139        }
1140    }
1141
1142    /// The fixture fonts' folder.
1143    fn fixtures() -> PathBuf {
1144        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fonts")
1145    }
1146
1147    /// `TINY` made 8 × 16, as narrow as the wide fixture is wide.
1148    fn narrow_8_by_16() -> BitmapFont {
1149        parse_bdf(&TINY.replace("FONTBOUNDINGBOX 4 4 0 -1", "FONTBOUNDINGBOX 8 16 0 -2")).unwrap()
1150    }
1151
1152    #[test]
1153    fn a_jis_font_is_read_as_unicode() {
1154        let (unicode, jis) = (wide_fixture(false), wide_fixture(true));
1155        assert_eq!(unicode.glyphs.keys().collect::<String>(), "一口日田");
1156        assert_eq!(jis, unicode);
1157        assert_eq!((jis.cell_w, jis.cell_h), (16, 16));
1158        assert_eq!(jis_char(0x2422), Some('あ'));
1159        assert_eq!(jis_char(0x3021), Some('亜'));
1160        for none in [0x2020, 0x7f21, 0x0024_2121, -1, 0x2e21] {
1161            assert_eq!(jis_char(none), None, "{none:#x}");
1162        }
1163        assert_eq!(Encoding::of_registry("\"jisx0208.1983\""), Encoding::Jis);
1164        assert_eq!(Encoding::of_registry("\"ISO10646\""), Encoding::Unicode);
1165    }
1166
1167    #[test]
1168    fn the_wide_glyphs_are_the_texts_the_narrow_font_lacks_and_the_projects() {
1169        let narrow = narrow_8_by_16();
1170        let wide = wide_fixture(false);
1171        // The text uses 田, 日 and A (the narrow font has A); the project
1172        // names 一. 口 is neither.
1173        let chars = AtlasChars::of_lang(&text("田日A"), "").with_wide("一");
1174        let atlas = build_wide_atlas(&narrow, Some(&wide), &chars);
1175        assert_eq!(atlas.def.glyphs.keys().collect::<String>(), "Ab");
1176        assert_eq!(atlas.def.wide.keys().collect::<String>(), "一日田");
1177        // After the narrow glyphs, two cells each.
1178        assert_eq!(atlas.def.wide[&'一'], 2);
1179        assert_eq!(atlas.def.wide[&'日'], 4);
1180        assert_eq!(atlas.def.problems(""), Vec::<String>::new());
1181        assert_eq!(
1182            atlas
1183                .def
1184                .image_problems(atlas.image.width, atlas.image.height),
1185            Vec::<String>::new()
1186        );
1187        // The pixels of 一 (its bar on rows 7 and 8), over its two cells.
1188        let lit = |x: u32, y: u32| {
1189            atlas.image.rgba[to_usize(i64::from((y * atlas.image.width + x) * 4 + 3))]
1190        };
1191        let rect = atlas.def.wide_rect('一').unwrap();
1192        assert_eq!((rect.x, rect.w), (16, 16));
1193        assert!((rect.x + 1..rect.x + 15).all(|x| lit(x, 7) == 255 && lit(x, 8) == 255));
1194        assert!((rect.x..rect.x + 16).all(|x| lit(x, 6) == 0));
1195        // Without the wide font, none; and none the narrow font has.
1196        let plain = build_atlas(&narrow, &chars);
1197        assert!(plain.def.wide.is_empty());
1198        let both = AtlasChars::of_lang(&text("田"), "").with_wide("A");
1199        let atlas = build_wide_atlas(&narrow, Some(&wide), &both);
1200        assert_eq!(atlas.def.wide.keys().collect::<String>(), "田");
1201        assert!(atlas.def.glyphs.contains_key(&'A'));
1202    }
1203
1204    #[test]
1205    fn a_wide_glyph_never_starts_in_a_rows_last_cell() {
1206        // 31 narrow glyphs leave the first row's last cell free: the wide
1207        // glyph starts the next row.
1208        let glyph = |c: char| {
1209            let code = u32::from(c);
1210            format!("STARTCHAR {c}\nENCODING {code}\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n")
1211        };
1212        let narrow: String = ('!'..'@').map(glyph).collect();
1213        let narrow = parse_bdf(&format!("FONTBOUNDINGBOX 8 16 0 -2\n{narrow}")).unwrap();
1214        assert_eq!(narrow.glyphs.len(), 31);
1215        let chars = AtlasChars::default().with_wide("一口");
1216        let atlas = build_wide_atlas(&narrow, Some(&wide_fixture(false)), &chars);
1217        assert_eq!(atlas.def.wide[&'一'], COLUMNS);
1218        assert_eq!(atlas.def.wide[&'口'], COLUMNS + 2);
1219        assert_eq!(atlas.def.problems(""), Vec::<String>::new());
1220        // The one-bit copy holds every cell up to the last wide glyph's
1221        // right half, each half an ordinary cell.
1222        let bits = one_bit(&atlas.def, &atlas.image).unwrap();
1223        assert_eq!(bits.len(), to_usize(i64::from(COLUMNS + 4) * 16));
1224        let right_half_of_one = to_usize(i64::from(COLUMNS + 1) * 16);
1225        assert_eq!(bits[right_half_of_one + 7], 0b1111_1110);
1226    }
1227
1228    #[test]
1229    fn run_writes_the_wide_glyphs_and_refuses_a_wide_font_of_another_size() {
1230        let dir = std::env::temp_dir().join(format!("shuyi-font-wide-{}", std::process::id()));
1231        fs::create_dir_all(&dir).unwrap();
1232        let bdf = dir.join("narrow.bdf");
1233        let question = "STARTCHAR q\nENCODING 63\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n";
1234        let narrow = TINY
1235            .replace("FONTBOUNDINGBOX 4 4 0 -1", "FONTBOUNDINGBOX 8 16 0 -2")
1236            .replace("ENDFONT", question);
1237        fs::write(&bdf, narrow).unwrap();
1238        let out = dir.join("out");
1239        let wide = fixtures().join("wide-16-jis.bdf");
1240        let chars = AtlasChars::of_lang(&text("日本"), "");
1241        let fonts = std::slice::from_ref(&bdf);
1242        let said = run(&rules(), "shuyi", fonts, &[unnamed(&wide)], &out, &chars).unwrap();
1243        assert_eq!(
1244            said,
1245            format!(
1246                "font-atlas: 3 glyphs and 1 wide, 256×16 px → {}\nfont-atlas: the fonts lack \
1247                 1 character(s) the text uses: '本' (U+672C)",
1248                out.display()
1249            )
1250        );
1251        let ron = fs::read_to_string(out.join("atlas.ron")).unwrap();
1252        assert!(
1253            ron.starts_with(
1254                "// Generated by `shuyi font-atlas` from narrow.bdf + wide-16-jis.bdf."
1255            )
1256        );
1257        let def: FontAtlasDef = ron::from_str(&ron).unwrap();
1258        assert_eq!(def.wide, [('日', 3)].into());
1259        assert_eq!(fs::read(out.join(ONE_BIT_FILE)).unwrap().len(), 5 * 16);
1260        // Without wide glyphs, `atlas.ron` has no `wide` at all.
1261        let plain = build_atlas(&narrow_8_by_16(), &AtlasChars::default()).def;
1262        let text = atlas_ron(&plain, "a", "shuyi", None).unwrap();
1263        assert!(!text.contains("wide"), "{text}");
1264        assert_eq!(ron::from_str::<FontAtlasDef>(&text).unwrap(), plain);
1265        // A wide font not twice as wide is refused.
1266        let err = run(&rules(), "shuyi", fonts, &[unnamed(&bdf)], &out, &chars).unwrap_err();
1267        assert!(
1268            err.ends_with("a wide font's cells are twice as wide as the font's: 16×16, not 8×16"),
1269            "{err}"
1270        );
1271        fs::remove_dir_all(&dir).unwrap();
1272    }
1273
1274    /// English's `menu.title`, and a pack per `(code, lang.ron's extra
1275    /// fields, its text)`.
1276    fn packs(list: &[(&str, &str, &str)]) -> Lang {
1277        use shuyi_assets::lang::{LangCode, PackRules, pack_from_sources};
1278        let english: BTreeMap<String, String> =
1279            [("menu.title".to_owned(), "Start".to_owned())].into();
1280        let rules = PackRules {
1281            data_problem: |_, _| None,
1282            tokens: &shuyi_assets::dialogue::NoTokens,
1283            limits: shuyi_assets::dialogue::DialogueLimits {
1284                box_width: 40,
1285                box_lines: 3,
1286                max_text: 200,
1287                max_option: 30,
1288                min_options: 2,
1289                max_options: 4,
1290                max_reaction_texts: 3,
1291            },
1292            widths: &shuyi_assets::font::CellWidths::NARROW,
1293            variants: &[],
1294        };
1295        let packs = list
1296            .iter()
1297            .map(|&(code, extra, text)| {
1298                let info = format!(r#"(name: "{code}", made_by: Machine{extra})"#);
1299                let ui = format!(r#"[(key: "menu.title", source: "Start", text: "{text}")]"#);
1300                let pack =
1301                    pack_from_sources(("lang.ron", &info), ("ui.ron", &ui), &english, &rules);
1302                (LangCode::new(code).unwrap(), pack.unwrap())
1303            })
1304            .collect();
1305        Lang::new(english, packs)
1306    }
1307
1308    #[test]
1309    fn a_wide_font_names_its_set_before_an_equals_sign() {
1310        let parsed = |arg: &str| {
1311            let wide = WideFont::parse(arg);
1312            (wide.set, wide.path)
1313        };
1314        let path = |p: &str| PathBuf::from(p);
1315        assert_eq!(
1316            parsed("ja=fonts/ja.bdf"),
1317            (Some("ja".to_owned()), path("fonts/ja.bdf"))
1318        );
1319        assert_eq!(
1320            parsed("zh-Hans=zh.bdf"),
1321            (Some("zh-Hans".to_owned()), path("zh.bdf"))
1322        );
1323        assert_eq!(parsed("fonts/ja.bdf"), (None, path("fonts/ja.bdf")));
1324        // An `=` in a folder's name, or with no set before it, is a path's.
1325        assert_eq!(parsed("a=b/c.bdf"), (Some("a".to_owned()), path("b/c.bdf")));
1326        assert_eq!(parsed("fonts/a=b.bdf"), (None, path("fonts/a=b.bdf")));
1327        assert_eq!(parsed("fonts\\a=b.bdf"), (None, path("fonts\\a=b.bdf")));
1328        assert_eq!(parsed("=b.bdf"), (None, path("=b.bdf")));
1329    }
1330
1331    #[test]
1332    fn each_set_keeps_the_characters_of_the_languages_that_draw_from_it() {
1333        let narrow = narrow_8_by_16();
1334        let (ja, zh) = (
1335            read_font(&fixtures().join("wide-16-ja.bdf")).unwrap(),
1336            read_font(&fixtures().join("wide-16-zh.bdf")).unwrap(),
1337        );
1338        let lang = packs(&[
1339            ("ja", r#", wide_font: Some("ja")"#, "直日"),
1340            ("ko", "", "田"),
1341            (
1342                "zh-Hans",
1343                r#", wide_font: Some("zh-Hans"), wide_punctuation: true"#,
1344                "“直简”…",
1345            ),
1346        ]);
1347        let chars = AtlasChars::of_lang(&lang, "").with_wide_set(Some("ja"), "一");
1348        // Wide punctuation goes in the set of the language that has it
1349        // wide, even where the narrow font has it too.
1350        let mut with_ellipsis = narrow.clone();
1351        with_ellipsis.glyphs.insert('…', Vec::new());
1352        assert!(chars.wants_wide('…', &with_ellipsis, Some("zh-Hans")));
1353        assert!(!chars.wants_wide('…', &with_ellipsis, Some("ja")));
1354        assert!(!chars.wants_wide('…', &with_ellipsis, None));
1355        assert!(chars.wants_wide('一', &narrow, Some("ja")));
1356        assert!(!chars.wants_wide('一', &narrow, Some("zh-Hans")));
1357        let wides = [
1358            (Some("ja"), &ja),
1359            (Some("zh-Hans"), &zh),
1360            (None, &wide_fixture(false)),
1361        ];
1362        let atlas = build_atlas_with_sets(&narrow, &wides, &chars);
1363        let def = &atlas.def;
1364        let keys = |set: Option<&str>| def.wide_set(set).unwrap().keys().collect::<String>();
1365        // The ja font has no 一: the set keeps what it has.
1366        assert_eq!(keys(Some("ja")), "日直");
1367        assert_eq!(keys(Some("zh-Hans")), "“”…直简");
1368        // The unnamed set keeps the text of the languages that name none.
1369        assert_eq!(keys(None), "田");
1370        // One after another, in the order given, after the narrow glyphs.
1371        assert_eq!(def.glyphs.keys().collect::<String>(), "Ab");
1372        assert_eq!(
1373            (
1374                def.wide_cell(Some("ja"), '日'),
1375                def.wide_cell(Some("ja"), '直')
1376            ),
1377            (Some(2), Some(4))
1378        );
1379        assert_eq!(def.wide_cell(Some("zh-Hans"), '直'), Some(12));
1380        assert_eq!(def.wide_cell(None, '田'), Some(16));
1381        assert_eq!(def.problems(""), Vec::<String>::new());
1382        assert_eq!(
1383            def.image_problems(atlas.image.width, atlas.image.height),
1384            Vec::<String>::new()
1385        );
1386        // Every wide character is drawn from its language's set; only the
1387        // narrow letters the tiny narrow font lacks are left.
1388        let undrawn = shuyi_assets::font::undrawn(def, &lang);
1389        for c in "日直简“”…田".chars() {
1390            let said = describe(c);
1391            assert!(
1392                undrawn.iter().all(|e| !e.message.starts_with(&said)),
1393                "{undrawn:?}"
1394            );
1395        }
1396        // `atlas.ron` keeps the sets, and reads back the same.
1397        let ron = atlas_ron(def, "a", "shuyi", None).unwrap();
1398        assert!(ron.contains("wide_sets"), "{ron}");
1399        assert_eq!(ron::from_str::<FontAtlasDef>(&ron).unwrap(), *def);
1400        let bits = one_bit(def, &atlas.image).unwrap();
1401        assert_eq!(bits.len(), to_usize(i64::from(18 * 16)));
1402    }
1403
1404    #[test]
1405    fn the_source_keeps_its_characters_in_the_set_it_names() {
1406        use shuyi_assets::lang::{LangCode, SourceLang};
1407        let source = SourceLang::new(LangCode::new("zh-Hans").unwrap(), "中文")
1408            .with_wide_font("zh-Hans")
1409            .with_wide_punctuation(true);
1410        let ui: BTreeMap<String, String> = [("menu.title".to_owned(), "“直简”".to_owned())].into();
1411        let lang = Lang::new(ui.clone(), BTreeMap::new()).with_source_lang(source);
1412        let chars = AtlasChars::of_lang(&lang, "");
1413        let mut narrow = narrow_8_by_16();
1414        narrow.glyphs.insert('“', Vec::new());
1415        // Its characters and its wide punctuation go in its set, and no other.
1416        for c in "直简“".chars() {
1417            assert!(chars.wants_wide(c, &narrow, Some("zh-Hans")), "{c}");
1418            assert!(!chars.wants_wide(c, &narrow, None), "{c}");
1419        }
1420        // Without the fields, the unnamed set keeps them, the quotes narrow.
1421        let plain = Lang::new(ui, BTreeMap::new());
1422        let chars = AtlasChars::of_lang(&plain, "");
1423        assert!(chars.wants_wide('直', &narrow, None));
1424        assert!(!chars.wants_wide('“', &narrow, None));
1425        assert!(!chars.wants_wide('直', &narrow, Some("zh-Hans")));
1426    }
1427
1428    #[test]
1429    fn run_writes_named_sets_and_refuses_two_fonts_for_one() {
1430        let dir = std::env::temp_dir().join(format!("shuyi-font-sets-{}", std::process::id()));
1431        fs::create_dir_all(&dir).unwrap();
1432        let bdf = dir.join("narrow.bdf");
1433        let question = "STARTCHAR q\nENCODING 63\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n";
1434        let narrow = TINY
1435            .replace("FONTBOUNDINGBOX 4 4 0 -1", "FONTBOUNDINGBOX 8 16 0 -2")
1436            .replace("ENDFONT", question);
1437        fs::write(&bdf, narrow).unwrap();
1438        let out = dir.join("out");
1439        let set = |set: &str, file: &str| WideFont {
1440            set: Some(set.to_owned()),
1441            path: fixtures().join(file),
1442        };
1443        let lang = packs(&[
1444            ("ja", r#", wide_font: Some("ja")"#, "直"),
1445            ("zh-Hans", r#", wide_font: Some("zh-Hans")"#, "直简"),
1446        ]);
1447        let chars = AtlasChars::of_lang(&lang, "");
1448        let fonts = std::slice::from_ref(&bdf);
1449        let wides = [
1450            set("ja", "wide-16-ja.bdf"),
1451            set("zh-Hans", "wide-16-zh.bdf"),
1452        ];
1453        let said = run(&rules(), "shuyi", fonts, &wides, &out, &chars).unwrap();
1454        assert!(
1455            said.starts_with("font-atlas: 3 glyphs and wide sets ja 1, zh-Hans 2, 256×16 px"),
1456            "{said}"
1457        );
1458        let ron = fs::read_to_string(out.join("atlas.ron")).unwrap();
1459        assert!(
1460            ron.starts_with(
1461                "// Generated by `shuyi font-atlas` from narrow.bdf + wide-16-ja.bdf + \
1462                 wide-16-zh.bdf."
1463            ),
1464            "{ron}"
1465        );
1466        let def: FontAtlasDef = ron::from_str(&ron).unwrap();
1467        assert!(def.wide.is_empty());
1468        assert_eq!(def.wide_cell(Some("zh-Hans"), '简'), Some(7));
1469        // The unnamed set beside a named one is named so in the summary.
1470        let both = [
1471            set("ja", "wide-16-ja.bdf"),
1472            unnamed(&fixtures().join("wide-16.bdf")),
1473        ];
1474        let said = run(&rules(), "shuyi", fonts, &both, &out, &chars).unwrap();
1475        assert!(said.contains(" and wide sets ja 1, unnamed 0,"), "{said}");
1476        // One font a set.
1477        let twice = [set("ja", "wide-16-ja.bdf"), set("ja", "wide-16-zh.bdf")];
1478        let err = run(&rules(), "shuyi", fonts, &twice, &out, &chars).unwrap_err();
1479        assert_eq!(err, "two wide fonts for the set \"ja\"");
1480        let twice = [
1481            unnamed(&fixtures().join("wide-16.bdf")),
1482            unnamed(&fixtures().join("wide-16.bdf")),
1483        ];
1484        let err = run(&rules(), "shuyi", fonts, &twice, &out, &chars).unwrap_err();
1485        assert_eq!(err, "two wide fonts for the unnamed set");
1486        fs::remove_dir_all(&dir).unwrap();
1487    }
1488
1489    #[test]
1490    fn the_one_bit_atlas_packs_rows_left_pixel_highest() {
1491        let atlas = build_atlas(&parse_bdf(TINY).unwrap(), &AtlasChars::default());
1492        let bits = one_bit(&atlas.def, &atlas.image).unwrap();
1493        // 'A' (index 0) then 'b' (index 1), four rows each.
1494        assert_eq!(bits, [0, 0b1100_0000, 0b1000_0000, 0, 0, 0, 0, 0b0010_0000]);
1495        let wide = FontAtlasDef {
1496            cell_w: 9,
1497            ..atlas.def
1498        };
1499        assert_eq!(
1500            one_bit(&wide, &atlas.image).unwrap_err(),
1501            "the one-bit atlas holds cells up to 8 pixels wide, not 9"
1502        );
1503        let empty = FontAtlasDef::default();
1504        let found = one_bit(&empty, &Image::default()).unwrap();
1505        assert!(found.is_empty(), "{found:?}");
1506    }
1507}