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    use skrifa::MetadataProvider as _;
64    let font = skrifa::FontRef::new(bytes).ok()?;
65    // Name IDs from the OpenType `name` table, in display preference.
66    for want in [
67        skrifa::string::StringId::FULL_NAME,
68        skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME,
69        skrifa::string::StringId::FAMILY_NAME,
70    ] {
71        // `localized_strings` decodes the platform/encoding records and yields
72        // only those it can turn into Unicode.
73        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/// Styling for a text label drawn via [`MapLike::draw_text_label`]: the pixel
85/// size plus foreground and drop-shadow colours. The font is passed separately
86/// so the caller keeps ownership of it.
87#[derive(Debug, Clone, Copy)]
88pub struct LabelStyle {
89    /// Pixel size to render glyphs at.
90    pub scale: ab_glyph::PxScale,
91    /// Foreground colour for the glyphs.
92    pub fg: image::Rgba<u8>,
93    /// Drop-shadow colour rendered one pixel down-right of the foreground.
94    pub shadow: image::Rgba<u8>,
95    /// Horizontal alignment of each line within the multi-line block (the block
96    /// is as wide as its widest line). Single-line labels are unaffected.
97    pub align: crate::coverage::HAlign,
98}
99
100/// Vertical advance between successive lines of a multi-line label, in pixels
101/// (before rounding): the scaled font height plus its line gap. Shared by
102/// [`measure_text`] and [`draw_multi_line_with_shadow`] so the fit check and
103/// the drawing round the *same* value and agree exactly on line stacking
104/// (measuring used to ceil the two metrics separately and so could over-report
105/// the height by up to one pixel per line).
106fn 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/// Measure the rendered pixel size `(width, height)` of a multi-line text
112/// block at the given font and pixel size, where width is the widest line and
113/// height is the total stacked height of `lines.len()` rows. Use this to check
114/// whether a label will fit a target area before drawing it.
115#[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
145/// Draw `text` at integer pixel `(x, y)` (top-left of the glyph bounding box)
146/// with a single-pixel drop shadow under the foreground, for legibility on
147/// varied tile backgrounds.
148pub(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    // Shadow at (+1, +1), drawn first so the foreground overlays it.
160    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
172/// Draw a sequence of lines stacked vertically starting at `(x, y)` (top-left
173/// of the first line), each with [`draw_text_with_shadow`].
174pub(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    // Block width is the widest line; each line is aligned within it so the
191    // per-line alignment is independent of the block's placement in the slot.
192    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    /// The font checked in at the workspace root, used to exercise the
223    /// `name`-table extraction offline.
224    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        // a missing file is a read error, not a parse error
254        assert!(matches!(
255            load_font(std::path::Path::new("/no/such/font.ttf")),
256            Err(FontError::Read { .. })
257        ));
258        Ok(())
259    }
260
261    /// Load the repository's bundled DejaVuSans font for text tests.
262    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    /// A blank 352x352 RGBA map (11 sims at zoom 4) for drawing tests.
268    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        // two lines are exactly twice as tall and at least as wide
284        assert_eq!(two.1, one.1 * 2);
285        assert!(two.0 >= one.0);
286        // no lines measures to nothing
287        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        // some pixel in the label region must now be non-transparent
303        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        // a short line stacked over a wide line: the short line's horizontal
322        // position within the block depends on the per-line alignment.
323        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        // Leftmost non-transparent x across the first line's rows.
333        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}