1use crate::resources::{FontId, Resources};
11use banished_formats::ui::Sprite;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct PlacedGlyph {
15 pub glyph: usize,
17 pub x: i32,
18 pub y: i32,
19}
20
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct TextLayout {
24 pub glyphs: Vec<PlacedGlyph>,
26 pub width: i32,
28 pub height: i32,
30 pub missing_characters: Vec<char>,
33}
34
35struct Line {
36 glyphs: Vec<PlacedGlyph>,
37 width: i32,
38}
39
40fn 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 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 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 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 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}