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