bevy_image_font/
loader.rs

1//! Code for parsing an [`ImageFont`] off of an on-disk representation.
2
3#![expect(clippy::absolute_paths, reason = "false positives")]
4
5use std::io::Error as IoError;
6
7use bevy::{
8    asset::{io::Reader, AssetLoader, LoadContext, LoadDirectError},
9    platform::collections::HashMap,
10    prelude::*,
11};
12use bevy_image::{Image, ImageSampler, ImageSamplerDescriptor};
13use camino::{FromPathError, Utf8Path, Utf8PathBuf};
14use ron::de::SpannedError;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::{ImageFont, ImageFontCharacter};
19
20#[cfg(feature = "bmf")]
21mod bmf;
22#[cfg(feature = "bmf")]
23pub use bmf::*;
24
25/// Human-readable way to specify where the characters in an image font are.
26#[derive(Debug, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum ImageFontLayout {
29    /// Interprets the string as a "grid" and slices up the input image
30    /// accordingly. Leading and trailing newlines are stripped, but spaces
31    /// are not (since your font might use them as padding).
32    ///
33    /// ```rust
34    /// # use bevy_image_font::loader::*;
35    /// // note that we have a raw string *inside* a raw string here...
36    /// let s = r###"
37    ///
38    /// // this bit is the actual RON syntax
39    /// Automatic(r##"
40    ///  !"#$%&'()*+,-./0123
41    /// 456789:;<=>?@ABCDEFG
42    /// HIJKLMNOPQRSTUVWXYZ[
43    /// \]^_`abcdefghijklmno
44    /// pqrstuvwxyz{|}~
45    /// "##)
46    ///
47    /// "###;
48    /// let layout = ron::from_str::<ImageFontLayout>(s).unwrap();
49    /// ```
50    Automatic(String),
51
52    /// Manually specifies the top-left position of each character, where each
53    /// character has the same size. When writing this in RON, the syntax
54    /// will look like
55    ///
56    /// ```rust
57    /// # use bevy_image_font::loader::*;
58    /// let s = r#"
59    /// ManualMonospace(
60    ///   size: (4, 8),
61    ///   coords: {
62    ///      'a': (0, 0),
63    ///      'b': (10, 0)
64    ///   }
65    /// )
66    /// "#;
67    /// ron::from_str::<ImageFontLayout>(s).unwrap();
68    /// ```
69    ManualMonospace {
70        /// The size of each character, specified as a uniform width and height
71        /// in pixels. All characters are assumed to have the same dimensions.
72        size: UVec2,
73
74        /// A mapping from characters to their top-left positions within the
75        /// font image. Each position is given in pixel coordinates relative
76        /// to the top-left corner of the image.
77        coords: HashMap<char, UVec2>,
78    },
79
80    /// Fully specifies the bounds of each character. The most general case.
81    ///
82    /// ```rust
83    /// # use bevy_image_font::loader::*;
84    /// let s = r#"
85    /// Manual({
86    /// 'a': URect(min: (0, 0), max: (10, 20)),
87    /// 'b': URect(min: (20, 20), max: (25, 25))
88    /// })
89    /// "#;
90    /// ron::from_str::<ImageFontLayout>(s).unwrap();
91    /// ```
92    Manual(HashMap<char, URect>),
93}
94
95/// Errors that can show up during validation.
96#[derive(Debug, Error)]
97#[non_exhaustive]
98pub enum ImageFontLayoutValidationError {
99    /// The image width does not evenly divide the character count per line.
100    ///
101    /// This error occurs when the width of the provided image is not a multiple
102    /// of the number of characters per line specified in the layout.
103    #[error(
104        "Image width {width} is not an exact multiple of per-line character count \
105    {per_line_character_count}."
106    )]
107    InvalidImageWidth {
108        /// The width of the image being validated.
109        width: u32,
110        /// The number of characters per line in the layout.
111        per_line_character_count: u32,
112    },
113
114    /// The image height does not evenly divide the number of lines.
115    ///
116    /// This error occurs when the height of the provided image is not a
117    /// multiple of the number of lines in the layout.
118    #[error("Image height {height} is not an exact multiple of line count {line_count}.")]
119    InvalidImageHeight {
120        /// The height of the image being validated.
121        height: u32,
122        /// The number of lines in the layout.
123        line_count: u32,
124    },
125
126    /// A repeated character was found in an `Automatic` layout string.
127    ///
128    /// This error occurs when the same character appears multiple times in the
129    /// layout string, leading to conflicting placement definitions.
130    #[error(
131        "The character '{character}' appears more than once. The second appearance is in the \
132        layout string at row {row}, column {column}."
133    )]
134    AutomaticRepeatedCharacter {
135        /// The row in the layout string where the repeated character is
136        /// located.
137        row: usize,
138        /// The column in the layout string where the repeated character is
139        /// located.
140        column: usize,
141        /// The character that was repeated in the layout string.
142        character: char,
143    },
144}
145
146impl ImageFontLayout {
147    /// Given the image size, returns a map from each codepoint to its location.
148    #[expect(
149        clippy::cast_possible_truncation,
150        reason = "while usize can hold more data than u32, we're working on a number here that \
151        should be substantially smaller than even u32's capacity"
152    )]
153    fn into_character_rect_map(
154        self,
155        size: UVec2,
156    ) -> Result<HashMap<char, URect>, ImageFontLayoutValidationError> {
157        match self {
158            ImageFontLayout::Automatic(str) => {
159                // trim() removes whitespace, which is not what we want!
160                let str = str
161                    .trim_start_matches(['\r', '\n'])
162                    .trim_end_matches(['\r', '\n']);
163                #[expect(
164                    clippy::expect_used,
165                    reason = "this intentionally panics on an empty string. Should never happen as \
166                    the ImageFontLayout should always have been validated before this method gets \
167                    called"
168                )]
169                let max_chars_per_line = str
170                    .lines()
171                    // important: *not* l.len()
172                    .map(|line| line.chars().count())
173                    .max()
174                    .expect("can't create character map from an empty string")
175                    as u32;
176
177                if size.x % max_chars_per_line != 0 {
178                    return Err(ImageFontLayoutValidationError::InvalidImageWidth {
179                        width: size.x,
180                        per_line_character_count: max_chars_per_line,
181                    });
182                }
183                let line_count = str.lines().count() as u32;
184                if size.y % line_count != 0 {
185                    return Err(ImageFontLayoutValidationError::InvalidImageHeight {
186                        height: size.y,
187                        line_count,
188                    });
189                }
190
191                let mut rect_map =
192                    HashMap::with_capacity((max_chars_per_line * line_count) as usize);
193
194                let rect_width = size.x / max_chars_per_line;
195                let rect_height = size.y / line_count;
196
197                for (row, line) in str.lines().enumerate() {
198                    for (column, character) in line.chars().enumerate() {
199                        let rect = URect::new(
200                            rect_width * column as u32,
201                            rect_height * row as u32,
202                            rect_width * (column + 1) as u32,
203                            rect_height * (row + 1) as u32,
204                        );
205                        if rect_map.insert(character, rect).is_some() {
206                            return Err(
207                                ImageFontLayoutValidationError::AutomaticRepeatedCharacter {
208                                    row,
209                                    column,
210                                    character,
211                                },
212                            );
213                        }
214                    }
215                }
216
217                Ok(rect_map)
218            }
219            ImageFontLayout::ManualMonospace { size, coords } => Ok(coords
220                .into_iter()
221                .map(|(character, top_left)| {
222                    (character, URect::from_corners(top_left, size + top_left))
223                })
224                .collect()),
225            ImageFontLayout::Manual(urect_map) => Ok(urect_map),
226        }
227    }
228}
229
230/// On-disk representation of an [`ImageFont`], optimized to make it easy for
231/// humans to write these. See the docs for [`ImageFontLayout`]'s variants for
232/// information on how to write the syntax, or [the example font's RON asset].
233///
234/// [the example font's RON asset](https://github.com/ilyvion/bevy_image_font/blob/main/assets/example_font.image_font.ron)
235#[derive(Debug, Serialize, Deserialize)]
236pub struct ImageFontDescriptor {
237    /// The path to the image file containing the font glyphs, relative to the
238    /// RON file. This should be a valid path to a texture file that can be
239    /// loaded by the asset system.
240    image: Utf8PathBuf,
241
242    /// The layout description of the font, specifying how characters map to
243    /// regions within the image. This can use any of the variants provided
244    /// by [`ImageFontLayout`], allowing flexible configuration.
245    layout: ImageFontLayout,
246}
247
248/// Errors that can show up during validation.
249#[derive(Debug, Error)]
250#[non_exhaustive]
251pub enum ImageFontDescriptorValidationError {
252    /// The image path provided is empty.
253    #[error("Image path is empty.")]
254    EmptyImagePath,
255
256    /// The layout string used for automatic character placement is empty.
257    /// This error occurs when no characters are defined in the automatic layout
258    /// string.
259    #[error("Automatic layout string is empty.")]
260    EmptyLayoutString,
261}
262
263impl ImageFontDescriptor {
264    /// Creates a new `ImageFontDescriptor` instance with the provided image
265    /// path and font layout, performing validation to ensure the descriptor
266    /// is valid.
267    ///
268    /// # Parameters
269    /// - `image`: The path to the image file containing the font glyphs,
270    ///   relative to the RON file that described the font. This should be a
271    ///   valid path to a texture file that can be loaded by the asset system.
272    /// - `layout`: The layout description of the font, specifying how
273    ///   characters map to regions within the image. See [`ImageFontLayout`]
274    ///   for more details about the available layout configurations.
275    ///
276    /// # Returns
277    /// A new `ImageFontDescriptor` instance if validation succeeds.
278    ///
279    /// # Errors
280    /// Returns an [`ImageFontDescriptorValidationError`] if the provided values
281    /// do not pass validation.
282    pub fn new(
283        image: Utf8PathBuf,
284        layout: ImageFontLayout,
285    ) -> Result<Self, ImageFontDescriptorValidationError> {
286        let value = Self { image, layout };
287        value.validate()?;
288        Ok(value)
289    }
290
291    /// Validates the `ImageFontDescriptor` struct to ensure all required fields
292    /// are populated.
293    ///
294    /// # Errors
295    ///   - `ImageFontLoadError::EmptyImagePath` if the `image` path is empty.
296    ///   - `ImageFontLoadError::EmptyLayoutString` if the `layout` string for
297    ///     `Automatic` is empty.
298    fn validate(&self) -> Result<(), ImageFontDescriptorValidationError> {
299        if self.image.as_str().trim().is_empty() {
300            return Err(ImageFontDescriptorValidationError::EmptyImagePath);
301        }
302        if matches!(self.layout, ImageFontLayout::Automatic(ref layout) if layout.trim().is_empty())
303        {
304            return Err(ImageFontDescriptorValidationError::EmptyLayoutString);
305        }
306        Ok(())
307    }
308
309    /// Gets the path to the image file containing the font glyphs.
310    ///
311    /// This is the value of the `image` field. The path is relative to the
312    /// RON file and should point to a valid texture file.
313    ///
314    /// # Returns
315    /// A reference to the `Utf8PathBuf` containing the image file path.
316    #[must_use]
317    pub fn image(&self) -> &Utf8Path {
318        &self.image
319    }
320
321    /// Gets the layout description of the font.
322    ///
323    /// This is the value of the `layout` field, which specifies how characters
324    /// map to regions within the image. See [`ImageFontLayout`] for details
325    /// about the available variants.
326    ///
327    /// # Returns
328    /// A reference to the `ImageFontLayout` describing the font layout.
329    #[must_use]
330    pub fn layout(&self) -> &ImageFontLayout {
331        &self.layout
332    }
333}
334
335/// Loader for [`ImageFont`]s.
336#[derive(Debug, Default)]
337pub struct ImageFontLoader;
338
339/// Errors that can show up during font loading.
340#[derive(Debug, Error)]
341#[non_exhaustive]
342pub enum ImageFontLoadError {
343    /// Parsing the on-disk representation of the font failed. This typically
344    /// indicates a syntax or formatting error in the RON file.
345    #[error("couldn't parse on-disk representation: {0}")]
346    ParseFailure(#[from] SpannedError),
347
348    /// A validation error occurred on the `ImageFontDescriptor`. Inspect the
349    /// value of the inner error for details.
350    #[error("Font descriptor is invalid: {0}")]
351    DescriptorValidationError(#[from] ImageFontDescriptorValidationError),
352
353    /// A validation error occurred on the `ImageFontLayout`. Inspect the
354    /// value of the inner error for details.
355    #[error("Font layout is invalid: {0}")]
356    LayoutValidationError(#[from] ImageFontLayoutValidationError),
357
358    /// An I/O error occurred while loading the image font. This might happen
359    /// if the file cannot be accessed, is missing, or is corrupted.
360    #[error("i/o error when loading image font: {0}")]
361    Io(#[from] IoError),
362
363    /// Failed to load an asset directly. This is usually caused by an error
364    /// in the asset pipeline or a missing dependency.
365    #[error("failed to load asset: {0}")]
366    LoadDirect(Box<LoadDirectError>),
367
368    /// The path provided for the font's image was not loaded as an image. This
369    /// may occur if the file is in an unsupported format or if the path is
370    /// incorrect.
371    #[error("Path does not point to a valid image file: {0}")]
372    NotAnImage(Utf8PathBuf),
373
374    /// The path provided for the font's image was not loaded as an image. This
375    /// may occur if the file is in an unsupported format or if the path is
376    /// incorrect.
377    #[error("Path is not valid UTF-8: {0:?}")]
378    InvalidPath(FromPathError),
379
380    /// The asset path has no parent directory.
381    #[error("Asset path has no parent directory")]
382    MissingParentPath,
383}
384
385impl From<LoadDirectError> for ImageFontLoadError {
386    #[inline]
387    fn from(value: LoadDirectError) -> Self {
388        Self::LoadDirect(Box::new(value))
389    }
390}
391
392/// Configuration settings for the `ImageFontLoader`.
393#[derive(Debug, serde::Deserialize, serde::Serialize)]
394#[non_exhaustive]
395pub struct ImageFontLoaderSettings {
396    /// The [`ImageSampler`] to use during font image rendering. Determines
397    /// how the font's texture is sampled when scaling or transforming it.
398    ///
399    /// The default is `nearest`, which scales the image without blurring,
400    /// preserving a crisp, pixelated appearance. This is usually ideal for
401    /// pixel-art fonts.
402    pub image_sampler: ImageSampler,
403}
404
405impl Default for ImageFontLoaderSettings {
406    fn default() -> Self {
407        Self {
408            image_sampler: ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()),
409        }
410    }
411}
412
413impl AssetLoader for ImageFontLoader {
414    type Asset = ImageFont;
415
416    type Settings = ImageFontLoaderSettings;
417
418    type Error = ImageFontLoadError;
419
420    // NOTE: Until I or someone else thinks of a way to reliably run `AssetLoaders`
421    //       in a unit test, parts of this method will unfortunately remain
422    //       uncovered by tests.
423    async fn load(
424        &self,
425        reader: &mut dyn Reader,
426        settings: &Self::Settings,
427        load_context: &mut LoadContext<'_>,
428    ) -> Result<Self::Asset, Self::Error> {
429        let font_descriptor = read_and_validate_font_descriptor(reader).await?;
430
431        // need the image loaded immediately because we need its size
432        let image_path = load_context
433            .path()
434            .parent()
435            .ok_or(ImageFontLoadError::MissingParentPath)?
436            .join(font_descriptor.image());
437        let Some(mut image) = load_context
438            .loader()
439            .immediate()
440            .with_unknown_type()
441            .load(image_path.as_path())
442            .await?
443            .take::<Image>()
444        else {
445            let path = match Utf8PathBuf::try_from(image_path) {
446                Ok(path) => path,
447                Err(error) => return Err(ImageFontLoadError::InvalidPath(error.from_path_error())),
448            };
449            return Err(ImageFontLoadError::NotAnImage(path));
450        };
451
452        image.sampler = settings.image_sampler.clone();
453        let size = image.size();
454
455        let (atlas_character_map, layout) =
456            descriptor_to_character_map_and_layout(font_descriptor, size)?;
457
458        let image_handle = load_context.add_labeled_asset(String::from("texture"), image);
459        let layout_handle = load_context.add_labeled_asset(String::from("layout"), layout);
460
461        let image_font = ImageFont::new(
462            vec![image_handle],
463            atlas_character_map,
464            vec![layout_handle],
465            settings.image_sampler.clone(),
466        );
467        Ok(image_font)
468    }
469
470    fn extensions(&self) -> &[&str] {
471        &["image_font.ron"]
472    }
473}
474
475/// Reads and validates an `ImageFontDescriptor` from a reader.
476///
477/// This function reads the entirety of the data provided by the `reader`,
478/// deserializes it into an `ImageFontDescriptor`, and performs validation
479/// to ensure the descriptor is valid.
480///
481/// # Parameters
482/// - `reader`: A mutable reference to an object implementing the [`Reader`]
483///   trait. This reader provides the serialized data for the font descriptor.
484///
485/// # Returns
486/// A `Result` containing either a valid `ImageFontDescriptor` or an error if
487/// reading, deserialization, or validation fails.
488///
489/// # Errors
490/// Returns an error in the following cases:
491/// - If reading from the `reader` fails.
492/// - If the data cannot be deserialized into an `ImageFontDescriptor`.
493/// - If the resulting `ImageFontDescriptor` does not pass validation.
494async fn read_and_validate_font_descriptor(
495    reader: &mut dyn Reader,
496) -> Result<ImageFontDescriptor, ImageFontLoadError> {
497    // Read data
498    let mut data = Vec::new();
499    reader.read_to_end(&mut data).await?;
500
501    // Deserialize into ImageFontDescriptor and validate
502    let font_descriptor: ImageFontDescriptor = ron::de::from_bytes(&data)?;
503    font_descriptor.validate()?;
504
505    Ok(font_descriptor)
506}
507
508/// Converts an `ImageFontDescriptor` into a character map and texture atlas
509/// layout.
510///
511/// This function processes the given `ImageFontDescriptor` to generate a
512/// character-to-index map and a [`TextureAtlasLayout`], based on the provided
513/// image size. It uses the descriptor's layout information to map characters to
514/// specific regions within the texture atlas.
515///
516/// # Parameters
517/// - `font_descriptor`: The `ImageFontDescriptor` containing the layout.
518/// - `image_size`: A [`UVec2`] representing the dimensions of the image
519///   containing the font glyphs.
520///
521/// # Returns
522/// A tuple where
523/// - the first element is a `HashMap<char, usize>` mapping characters to
524///   indices in the texture atlas.
525/// - the second element is a [`TextureAtlasLayout`] describing the texture
526///   atlas layout.
527///
528/// # Errors
529/// This function will return an [`ImageFontLoadError`] in the following cases:
530/// - If there are any validation errors in the layout. See
531///   [`ImageFontLayoutValidationError`] for details.
532fn descriptor_to_character_map_and_layout(
533    font_descriptor: ImageFontDescriptor,
534    image_size: UVec2,
535) -> Result<(HashMap<char, ImageFontCharacter>, TextureAtlasLayout), ImageFontLoadError> {
536    let rect_character_map = font_descriptor.layout.into_character_rect_map(image_size)?;
537    let (atlas_character_map, layout) =
538        ImageFont::mapped_atlas_layout_from_char_map(0, image_size, rect_character_map.into_iter());
539    Ok((atlas_character_map, layout))
540}
541
542#[cfg(test)]
543mod tests;