1use std::path::{Path, PathBuf};
9
10use ab_glyph::{Font, ScaleFont as _};
11
12use crate::map_tiles::MapLike;
13
14#[derive(Debug, thiserror::Error)]
16pub enum FontError {
17 #[error("could not read font file `{path}`: {source}")]
19 Read {
20 path: PathBuf,
22 source: std::io::Error,
24 },
25 #[error("could not parse font file as a TrueType/OpenType font: {0}")]
27 Parse(#[from] ab_glyph::InvalidFont),
28}
29
30pub fn load_font(path: &Path) -> Result<ab_glyph::FontVec, FontError> {
39 let bytes = std::fs::read(path).map_err(|source| FontError::Read {
40 path: path.to_owned(),
41 source,
42 })?;
43 Ok(ab_glyph::FontVec::try_from_vec(bytes)?)
44}
45
46#[must_use]
51pub fn display_name_from_file_name(file_name: &str) -> String {
52 let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s);
53 stem.replace(['-', '_'], " ")
54}
55
56#[must_use]
62pub fn embedded_font_name(bytes: &[u8]) -> Option<String> {
63 let face = ttf_parser::Face::parse(bytes, 0).ok()?;
64 let names = face.names();
65 for want in [4u16, 16, 1] {
67 for i in 0..names.len() {
68 let Some(name) = names.get(i) else { continue };
69 if name.name_id != want || !name.is_unicode() {
70 continue;
71 }
72 if let Some(decoded) = name.to_string() {
75 let trimmed = decoded.trim();
76 if !trimmed.is_empty() {
77 return Some(trimmed.to_owned());
78 }
79 }
80 }
81 }
82 None
83}
84
85#[derive(Debug, Clone, Copy)]
89pub struct LabelStyle {
90 pub scale: ab_glyph::PxScale,
92 pub fg: image::Rgba<u8>,
94 pub shadow: image::Rgba<u8>,
96 pub align: crate::coverage::HAlign,
99}
100
101fn line_advance_px<F: Font>(scale: ab_glyph::PxScale, font: &F) -> f32 {
108 let scaled = font.as_scaled(scale);
109 scaled.height() + scaled.line_gap()
110}
111
112#[must_use]
117#[expect(
118 clippy::module_name_repetitions,
119 reason = "measure_text reads naturally at call sites; the text module groups text helpers"
120)]
121pub fn measure_text<F: Font>(scale: ab_glyph::PxScale, font: &F, lines: &[String]) -> (u32, u32) {
122 if lines.is_empty() {
123 return (0, 0);
124 }
125 #[expect(
126 clippy::cast_possible_truncation,
127 clippy::cast_sign_loss,
128 reason = "font line metrics for a sane pixel size are small positive values, nowhere near u32::MAX"
129 )]
130 let line_height = line_advance_px(scale, font).ceil() as u32;
131 let mut max_w: u32 = 0;
132 for line in lines {
133 let (w, _) = imageproc::drawing::text_size(scale, font, line);
134 if w > max_w {
135 max_w = w;
136 }
137 }
138 #[expect(
139 clippy::cast_possible_truncation,
140 reason = "line counts for any real label are tiny, nowhere near u32::MAX"
141 )]
142 let total_h = line_height * lines.len() as u32;
143 (max_w, total_h)
144}
145
146pub(crate) fn draw_text_with_shadow<M, F>(
150 map: &mut M,
151 x: i32,
152 y: i32,
153 style: &LabelStyle,
154 font: &F,
155 text: &str,
156) where
157 M: MapLike + ?Sized,
158 F: Font,
159{
160 imageproc::drawing::draw_text_mut(
162 map.image_mut(),
163 style.shadow,
164 x + 1,
165 y + 1,
166 style.scale,
167 font,
168 text,
169 );
170 imageproc::drawing::draw_text_mut(map.image_mut(), style.fg, x, y, style.scale, font, text);
171}
172
173pub(crate) fn draw_multi_line_with_shadow<M, F>(
176 map: &mut M,
177 x: i32,
178 y: i32,
179 style: &LabelStyle,
180 font: &F,
181 lines: &[String],
182) where
183 M: MapLike + ?Sized,
184 F: Font,
185{
186 #[expect(
187 clippy::cast_possible_truncation,
188 reason = "font line metrics for a sane pixel size are small positive values, nowhere near i32::MAX"
189 )]
190 let line_height = line_advance_px(style.scale, font).ceil() as i32;
191 let mut block_w: u32 = 0;
194 for line in lines {
195 let (w, _) = imageproc::drawing::text_size(style.scale, font, line);
196 if w > block_w {
197 block_w = w;
198 }
199 }
200 for (i, line) in lines.iter().enumerate() {
201 #[expect(
202 clippy::cast_possible_truncation,
203 clippy::cast_possible_wrap,
204 reason = "line counts for any real label are tiny, nowhere near i32::MAX"
205 )]
206 let line_y = y + line_height * i as i32;
207 let (line_w, _) = imageproc::drawing::text_size(style.scale, font, line);
208 #[expect(
209 clippy::cast_possible_wrap,
210 reason = "the alignment offset is a small pixel value, nowhere near i32::MAX"
211 )]
212 let x_off = style.align.offset(line_w, block_w) as i32;
213 draw_text_with_shadow(map, x + x_off, line_y, style, font, line);
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::map_tiles::Map;
221 use sl_types::map::{GridCoordinates, GridRectangle, ZoomLevel};
222
223 const DEJAVU: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf"));
226
227 #[test]
228 fn display_name_drops_extension_and_replaces_separators() {
229 assert_eq!(display_name_from_file_name("DejaVuSans.ttf"), "DejaVuSans");
230 assert_eq!(
231 display_name_from_file_name("noto-sans-mono.ttf"),
232 "noto sans mono"
233 );
234 assert_eq!(
235 display_name_from_file_name("source_code_pro.ttf"),
236 "source code pro"
237 );
238 }
239
240 #[test]
241 fn embedded_name_extracts_full_name() {
242 assert_eq!(embedded_font_name(DEJAVU).as_deref(), Some("DejaVu Sans"));
243 }
244
245 #[test]
246 fn embedded_name_rejects_garbage() {
247 assert_eq!(embedded_font_name(b"not a font"), None);
248 }
249
250 #[test]
251 fn load_font_reads_and_parses() -> Result<(), Box<dyn std::error::Error>> {
252 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
253 load_font(std::path::Path::new(path))?;
254 assert!(matches!(
256 load_font(std::path::Path::new("/no/such/font.ttf")),
257 Err(FontError::Read { .. })
258 ));
259 Ok(())
260 }
261
262 fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
264 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
265 Ok(ab_glyph::FontVec::try_from_vec(std::fs::read(path)?)?)
266 }
267
268 fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
270 let grid = GridRectangle::new(
271 GridCoordinates::new(1130, 1130),
272 GridCoordinates::new(1140, 1140),
273 );
274 Ok(Map::blank(grid, ZoomLevel::try_new(4)?))
275 }
276
277 #[test]
278 fn measure_text_stacks_lines() -> Result<(), Box<dyn std::error::Error>> {
279 let font = test_font()?;
280 let scale = ab_glyph::PxScale::from(16.0);
281 let one = measure_text(scale, &font, &["Hello".to_owned()]);
282 let two = measure_text(scale, &font, &["Hello".to_owned(), "Worldly".to_owned()]);
283 assert!(one.0 > 0 && one.1 > 0);
284 assert_eq!(two.1, one.1 * 2);
286 assert!(two.0 >= one.0);
287 assert_eq!(measure_text(scale, &font, &[]), (0, 0));
289 Ok(())
290 }
291
292 #[test]
293 fn draw_text_label_writes_pixels_near_origin() -> Result<(), Box<dyn std::error::Error>> {
294 let font = test_font()?;
295 let mut map = blank_map()?;
296 let style = LabelStyle {
297 scale: ab_glyph::PxScale::from(20.0),
298 fg: image::Rgba([255, 255, 255, 255]),
299 shadow: image::Rgba([0, 0, 0, 200]),
300 align: crate::coverage::HAlign::Left,
301 };
302 map.draw_text_label((40, 40), &["Label".to_owned()], &style, &font);
303 let mut drawn = false;
305 for y in 40..80u32 {
306 for x in 40..200u32 {
307 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
308 if a != 0 {
309 drawn = true;
310 }
311 }
312 }
313 assert!(drawn, "draw_text_label should write visible pixels");
314 Ok(())
315 }
316
317 #[test]
318 fn multi_line_aligns_each_line_within_the_block() -> Result<(), Box<dyn std::error::Error>> {
319 use crate::coverage::HAlign;
320 let font = test_font()?;
321 let scale = ab_glyph::PxScale::from(24.0);
322 let lines = vec!["I".to_owned(), "WWWWWWWW".to_owned()];
325 let scaled = font.as_scaled(scale);
326 #[expect(
327 clippy::cast_possible_truncation,
328 clippy::cast_sign_loss,
329 reason = "test"
330 )]
331 let line_h = (scaled.height() + scaled.line_gap()).ceil() as u32;
332
333 let first_line_min_x = |map: &Map| -> Option<u32> {
335 let mut found: Option<u32> = None;
336 for y in 10..(10 + line_h) {
337 for x in 0..352u32 {
338 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
339 if a != 0 {
340 found = Some(found.map_or(x, |m| m.min(x)));
341 }
342 }
343 }
344 found
345 };
346
347 let mut left = blank_map()?;
348 let left_style = LabelStyle {
349 scale,
350 fg: image::Rgba([255, 255, 255, 255]),
351 shadow: image::Rgba([0, 0, 0, 200]),
352 align: HAlign::Left,
353 };
354 left.draw_text_label((10, 10), &lines, &left_style, &font);
355
356 let mut right = blank_map()?;
357 let right_style = LabelStyle {
358 align: HAlign::Right,
359 ..left_style
360 };
361 right.draw_text_label((10, 10), &lines, &right_style, &font);
362
363 let left_min = first_line_min_x(&left).ok_or("left first line drew nothing")?;
364 let right_min = first_line_min_x(&right).ok_or("right first line drew nothing")?;
365 assert!(
366 right_min > left_min + 10,
367 "the short first line should shift right under right alignment (left={left_min}, right={right_min})"
368 );
369 Ok(())
370 }
371}