Skip to main content

sl_map_apis/
text.rs

1//! Text-drawing helpers usable on any [`MapLike`]: measuring multi-line text
2//! and drawing free-floating labels with a drop shadow. These are not tied to
3//! any overlay type — the GLW overlay renderer and the placement-label
4//! renderer both build on them.
5//!
6//! The library does not bundle a font; callers supply any `ab_glyph::Font`.
7
8use std::path::{Path, PathBuf};
9
10use ab_glyph::{Font, ScaleFont as _};
11
12use crate::map_tiles::MapLike;
13
14/// Errors from [`load_font`].
15#[derive(Debug, thiserror::Error)]
16pub enum FontError {
17    /// the font file could not be read
18    #[error("could not read font file `{path}`: {source}")]
19    Read {
20        /// the path that failed to read
21        path: PathBuf,
22        /// the underlying IO error
23        source: std::io::Error,
24    },
25    /// the bytes could not be parsed as a TrueType/OpenType font
26    #[error("could not parse font file as a TrueType/OpenType font: {0}")]
27    Parse(#[from] ab_glyph::InvalidFont),
28}
29
30/// Load and parse a TrueType/OpenType font file into an owned
31/// [`ab_glyph::FontVec`]. The path is included in the error on a read
32/// failure.
33///
34/// # Errors
35///
36/// Returns [`FontError::Read`] if the file cannot be read, or
37/// [`FontError::Parse`] if it is not a valid font.
38pub 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/// Derive a friendly display name from a font's file name by stripping the
47/// extension and replacing `-`/`_` with spaces (`noto-sans-mono.ttf` →
48/// `noto sans mono`). Used as a fallback when a font has no usable embedded
49/// name (see [`embedded_font_name`]).
50#[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/// Extract a human-readable name from a font's OpenType `name` table.
57/// Prefers the full font name (id 4), then the typographic family (id 16),
58/// then the legacy family (id 1). Returns `None` when the bytes cannot be
59/// parsed or have no decodable, non-empty record (the caller can then fall
60/// back to [`display_name_from_file_name`]).
61#[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    // Name IDs from the OpenType `name` table, in display preference.
66    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            // `to_string` decodes Windows/Unicode UTF-16BE records and
73            // returns `None` for encodings it cannot handle.
74            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/// Styling for a text label drawn via [`MapLike::draw_text_label`]: the pixel
86/// size plus foreground and drop-shadow colours. The font is passed separately
87/// so the caller keeps ownership of it.
88#[derive(Debug, Clone, Copy)]
89pub struct LabelStyle {
90    /// Pixel size to render glyphs at.
91    pub scale: ab_glyph::PxScale,
92    /// Foreground colour for the glyphs.
93    pub fg: image::Rgba<u8>,
94    /// Drop-shadow colour rendered one pixel down-right of the foreground.
95    pub shadow: image::Rgba<u8>,
96    /// Horizontal alignment of each line within the multi-line block (the block
97    /// is as wide as its widest line). Single-line labels are unaffected.
98    pub align: crate::coverage::HAlign,
99}
100
101/// Vertical advance between successive lines of a multi-line label, in pixels
102/// (before rounding): the scaled font height plus its line gap. Shared by
103/// [`measure_text`] and [`draw_multi_line_with_shadow`] so the fit check and
104/// the drawing round the *same* value and agree exactly on line stacking
105/// (measuring used to ceil the two metrics separately and so could over-report
106/// the height by up to one pixel per line).
107fn 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/// Measure the rendered pixel size `(width, height)` of a multi-line text
113/// block at the given font and pixel size, where width is the widest line and
114/// height is the total stacked height of `lines.len()` rows. Use this to check
115/// whether a label will fit a target area before drawing it.
116#[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
146/// Draw `text` at integer pixel `(x, y)` (top-left of the glyph bounding box)
147/// with a single-pixel drop shadow under the foreground, for legibility on
148/// varied tile backgrounds.
149pub(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    // Shadow at (+1, +1), drawn first so the foreground overlays it.
161    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
173/// Draw a sequence of lines stacked vertically starting at `(x, y)` (top-left
174/// of the first line), each with [`draw_text_with_shadow`].
175pub(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    // Block width is the widest line; each line is aligned within it so the
192    // per-line alignment is independent of the block's placement in the slot.
193    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    /// The font checked in at the workspace root, used to exercise the
224    /// `name`-table extraction offline.
225    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        // a missing file is a read error, not a parse error
255        assert!(matches!(
256            load_font(std::path::Path::new("/no/such/font.ttf")),
257            Err(FontError::Read { .. })
258        ));
259        Ok(())
260    }
261
262    /// Load the repository's bundled DejaVuSans font for text tests.
263    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    /// A blank 352x352 RGBA map (11 sims at zoom 4) for drawing tests.
269    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        // two lines are exactly twice as tall and at least as wide
285        assert_eq!(two.1, one.1 * 2);
286        assert!(two.0 >= one.0);
287        // no lines measures to nothing
288        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        // some pixel in the label region must now be non-transparent
304        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        // a short line stacked over a wide line: the short line's horizontal
323        // position within the block depends on the per-line alignment.
324        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        // Leftmost non-transparent x across the first line's rows.
334        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}