Skip to main content

banished_ui/
text.rs

1//! Text layout following `UI::Font::ComputeStringLayout` / `ComputeLineLayout`.
2//!
3//! Glyph metrics are `(a, c, b, d)`: the pen advances by `a` plus the previous glyph's `c`, the
4//! glyph is drawn there `b` pixels wide, and `d` raises it relative to the baseline. Lines advance
5//! by the font size; `\n` breaks lines and, when wrapping, lines break at the last space before the
6//! limit.
7//!
8//! Original: Runtime `0x180119860`, Runtime `0x180119500`.
9
10use crate::resources::{FontId, Resources};
11use banished_formats::ui::Sprite;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct PlacedGlyph {
15    /// Glyph index, which is also the sprite index in the font's sheet.
16    pub glyph: usize,
17    pub x: i32,
18    pub y: i32,
19}
20
21#[derive(Debug, Clone, Default, PartialEq)]
22/// Laid-out text: each glyph's place, and the size of the whole.
23pub struct TextLayout {
24    /// The glyphs, each with its position.
25    pub glyphs: Vec<PlacedGlyph>,
26    /// The width in pixels.
27    pub width: i32,
28    /// The height in pixels.
29    pub height: i32,
30    /// Distinct input characters absent from the font, rendered using the fallback policy.
31    /// Kept even when the font has no replacement glyph, so missing text is diagnosable.
32    pub missing_characters: Vec<char>,
33}
34
35struct Line {
36    glyphs: Vec<PlacedGlyph>,
37    width: i32,
38}
39
40/// Keep fallback rendering inside the game's atlas. The only synthesized character is a middle
41/// dot: its period bitmap is centered on the x-height. All other missing printable characters
42/// use the replacement character or question mark, preserving a visible advance during wrapping.
43fn resolve_glyph(
44    font: &banished_formats::ui::Font,
45    sprites: Option<&[Sprite]>,
46    ch: char,
47) -> Option<(usize, i32)> {
48    if let Some(glyph) = font.glyph_index(ch) {
49        return Some((glyph, 0));
50    }
51    if ch == '\u{00a0}' {
52        if let Some(glyph) = font.glyph_index(' ') {
53            return Some((glyph, 0));
54        }
55    }
56    if ch == '·' {
57        if let Some(((dot, body), sprites)) = font
58            .glyph_index('.')
59            .zip(font.glyph_index('x').or_else(|| font.glyph_index('H')))
60            .zip(sprites)
61        {
62            if let Some((dot_sprite, body_sprite)) = sprites.get(dot).zip(sprites.get(body)) {
63                let raised_bottom =
64                    font.glyphs[body][3] + (body_sprite.height - dot_sprite.height) / 2;
65                return Some((dot, raised_bottom - font.glyphs[dot][3]));
66            }
67        }
68    }
69    font.glyph_index('\u{fffd}')
70        .or_else(|| font.glyph_index('?'))
71        .map(|glyph| (glyph, 0))
72}
73
74fn layout_line(res: &Resources, font: FontId, text: &str, y: i32) -> Line {
75    let loaded = &res.fonts[font];
76    let sprites = loaded.sheet.map(|s| &res.sheets[s].sheet.sprites);
77    let (mut x, mut prev_c, mut glyphs) = (0, 0, Vec::new());
78    for ch in text.chars() {
79        let Some((g, raise)) = resolve_glyph(&loaded.font, sprites.map(Vec::as_slice), ch) else {
80            continue;
81        };
82        let [a, c, b, d] = loaded.font.glyphs[g];
83        x += a + prev_c;
84        let h = sprites.and_then(|s| s.get(g)).map_or(0, |s| s.height);
85        glyphs.push(PlacedGlyph {
86            glyph: g,
87            x,
88            y: y + loaded.ascent - d - h - raise,
89        });
90        x += b;
91        prev_c = c;
92    }
93    Line {
94        glyphs,
95        width: x + prev_c,
96    }
97}
98
99fn text_width(res: &Resources, font: FontId, text: &str) -> i32 {
100    layout_line(res, font, text, 0).width
101}
102
103impl TextLayout {
104    /// Lay out `text`. `wrap_width` enables word wrapping at that width; `justify` is 0 left,
105    /// 1 centre, 2 right (relative to the widest line).
106    pub fn compute(
107        res: &Resources,
108        font: FontId,
109        text: &str,
110        wrap_width: Option<i32>,
111        justify: u32,
112    ) -> TextLayout {
113        let mut missing_characters = Vec::new();
114        for ch in text.chars().filter(|ch| *ch != '\n') {
115            if res.fonts[font].font.glyph_index(ch).is_none() && !missing_characters.contains(&ch) {
116                missing_characters.push(ch);
117            }
118        }
119        if !missing_characters.is_empty() {
120            log::debug!(
121                "font {} uses fallback glyphs for {:?}",
122                res.fonts[font].font.name,
123                missing_characters
124            );
125        }
126        let line_height = res.fonts[font].font.size;
127        let mut lines: Vec<String> = Vec::new();
128        for para in text.split('\n') {
129            match wrap_width.filter(|w| *w > 0) {
130                Some(limit) => {
131                    let mut current = String::new();
132                    for word in para.split(' ') {
133                        let candidate = if current.is_empty() {
134                            word.to_string()
135                        } else {
136                            format!("{current} {word}")
137                        };
138                        if !current.is_empty() && text_width(res, font, &candidate) > limit {
139                            lines.push(std::mem::take(&mut current));
140                            current = word.to_string();
141                        } else {
142                            current = candidate;
143                        }
144                    }
145                    lines.push(current);
146                }
147                None => lines.push(para.to_string()),
148            }
149        }
150        let laid: Vec<Line> = lines
151            .iter()
152            .enumerate()
153            .map(|(i, l)| layout_line(res, font, l, i as i32 * line_height))
154            .collect();
155        let width = laid.iter().map(|l| l.width).max().unwrap_or(0);
156        let mut glyphs = Vec::new();
157        for line in laid {
158            let shift = match justify {
159                1 => (width - line.width) / 2,
160                2 => width - line.width,
161                _ => 0,
162            };
163            glyphs.extend(line.glyphs.into_iter().map(|g| PlacedGlyph {
164                x: g.x + shift,
165                ..g
166            }));
167        }
168        // `Font::ComputeStringLayout` (0x180119860): unwrapped text is always one line high,
169        // even empty; wrapped text has no lines when empty.
170        let height = if text.is_empty() && wrap_width.is_some_and(|w| w > 0) {
171            0
172        } else {
173            lines.len() as i32 * line_height
174        };
175        TextLayout {
176            glyphs,
177            width,
178            height,
179            missing_characters,
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::resources::{LoadedFont, LoadedSheet};
188    use banished_formats::pkg::Archive;
189    use banished_formats::ui::{Font, Sprite, SpriteSheet};
190    use std::rc::Rc;
191
192    fn resources() -> Resources {
193        let mut res = Resources::new(Rc::new(Archive::default()));
194        let chars = ['A', 'g', 'x', '.', '?', ' '];
195        let mut char_map = vec![-1; 128];
196        for (i, ch) in chars.into_iter().enumerate() {
197            char_map[ch as usize] = i as i32;
198        }
199        let font = Font {
200            name: "test".into(),
201            size: 16,
202            sheet_width: 64,
203            sheet_height: 64,
204            character_set: None,
205            sprite_sheet: None,
206            material: None,
207            char_map,
208            glyphs: vec![
209                [1, 1, 6, 0],
210                [1, 1, 6, -3],
211                [1, 1, 5, 0],
212                [1, 1, 2, 0],
213                [1, 1, 6, 0],
214                [0, 0, 4, 0],
215            ],
216            unknown: [4, 12, 4],
217        };
218        res.sheets.push(LoadedSheet {
219            reference: "test".into(),
220            image: None,
221            shader: None,
222            pixels: None,
223            sheet: SpriteSheet {
224                material_path: String::new(),
225                source_image: String::new(),
226                width: 64,
227                height: 64,
228                unknown_flag: false,
229                material: None,
230                sprites: [9, 9, 7, 2, 9, 0]
231                    .into_iter()
232                    .enumerate()
233                    .map(|(i, height)| Sprite {
234                        name: String::new(),
235                        source: String::new(),
236                        offset: [0, 0],
237                        width: font.glyphs[i][2],
238                        height,
239                        unknown: 0,
240                        uv: [0.0, 0.0, 1.0, 1.0],
241                    })
242                    .collect(),
243            },
244        });
245        res.fonts.push(LoadedFont {
246            ascent: font.baseline_offset(),
247            font,
248            sheet: Some(0),
249        });
250        res
251    }
252
253    #[test]
254    fn uses_serialized_descent_for_baseline_and_line_spacing() {
255        let res = resources();
256        // Tallest top above baseline is 9, but the serialized baseline is 16 - 4 = 12.
257        assert_eq!(res.fonts[0].font.baseline_offset(), 12);
258        let layout = TextLayout::compute(&res, 0, "Ag\nA", None, 0);
259        assert_eq!(
260            layout.glyphs.iter().map(|g| g.y).collect::<Vec<_>>(),
261            [3, 6, 19]
262        );
263        assert_eq!((layout.width, layout.height), (16, 32));
264    }
265
266    #[test]
267    fn centers_missing_middle_dot_in_x_height_with_original_atlas() {
268        let res = resources();
269        let layout = TextLayout::compute(&res, 0, "x·x", None, 0);
270        assert_eq!(
271            layout.glyphs.iter().map(|g| g.glyph).collect::<Vec<_>>(),
272            [2, 3, 2]
273        );
274        // x occupies y=5..12; a two-pixel period centered on it occupies y=8..10.
275        assert_eq!(layout.glyphs[1].y, 8);
276        assert_eq!(layout.missing_characters, ['·']);
277        assert_eq!(layout.width, 18);
278    }
279
280    #[test]
281    fn missing_characters_remain_visible_and_take_part_in_wrapping() {
282        let res = resources();
283        let layout = TextLayout::compute(&res, 0, "A ☃☃", Some(20), 0);
284        assert_eq!(
285            layout.glyphs.iter().map(|g| g.glyph).collect::<Vec<_>>(),
286            [0, 4, 4]
287        );
288        assert_eq!(layout.glyphs[1].y, 19);
289        assert_eq!((layout.width, layout.height), (16, 32));
290        assert_eq!(layout.missing_characters, ['☃']);
291    }
292
293    #[test]
294    fn direct_unicode_glyph_takes_precedence_over_fallback() {
295        let mut res = resources();
296        res.fonts[0].font.char_map.resize(184, -1);
297        res.fonts[0].font.char_map['·' as usize] = 4;
298        let layout = TextLayout::compute(&res, 0, "·", None, 0);
299        assert_eq!(
300            layout.glyphs[0],
301            PlacedGlyph {
302                glyph: 4,
303                x: 1,
304                y: 3
305            }
306        );
307        assert!(layout.missing_characters.is_empty());
308    }
309
310    #[test]
311    fn absent_replacement_is_reported_and_nonbreaking_space_keeps_its_advance() {
312        let mut res = resources();
313        res.fonts[0].font.char_map['?' as usize] = -1;
314        let layout = TextLayout::compute(&res, 0, "A\u{a0}☃A", None, 0);
315        assert_eq!(
316            layout.glyphs.iter().map(|g| g.glyph).collect::<Vec<_>>(),
317            [0, 5, 0]
318        );
319        assert_eq!(layout.width, 20);
320        assert_eq!(layout.missing_characters, ['\u{a0}', '☃']);
321    }
322}