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;