1use crate::error::Error;
2
3#[derive(Debug, Clone)]
4pub struct FntFile {
6 pub chunk_order: Vec<String>,
7 pub header: FntHeader,
8 pub palette: FntPalette,
9 pub glyphs: Vec<FntGlyph>,
10 pub rdat: Option<Vec<u8>>,
11 pub trailing_padding: Vec<u8>,
12}
13
14#[derive(Debug, Clone)]
15pub struct FntHeader {
17 pub description_raw: [u8; 32],
18 pub description_text: String,
19 pub unknown_24: u16,
20 pub has_rdat: u16,
21 pub unknown_28: u16,
22 pub unknown_2a: u16,
23 pub unknown_2c: u16,
24 pub max_width: u16,
25 pub line_height: u16,
26 pub character_start: u16,
27 pub character_count: u16,
28 pub unknown_36: u16,
29 pub unknown_38: u16,
30 pub has_palette: u16,
31}
32
33#[derive(Debug, Clone)]
34pub struct FntPalette {
36 pub tag: [u8; 4],
37 pub colors: Vec<[u8; 3]>,
38}
39
40#[derive(Debug, Clone)]
41pub struct FntGlyph {
43 pub enabled: u16,
44 pub offset_left: i16,
45 pub offset_top: i16,
46 pub width: u16,
47 pub height: u16,
48 pub pixels: Vec<u8>,
49}
50
51fn read_u16_le(bytes: &[u8], off: usize) -> Result<u16, Error> {
52 let end = off
53 .checked_add(2)
54 .ok_or_else(|| Error::Parse("u16 offset overflow".to_string()))?;
55 let slice = bytes
56 .get(off..end)
57 .ok_or_else(|| Error::Parse("unexpected EOF while reading u16".to_string()))?;
58 Ok(u16::from_le_bytes([slice[0], slice[1]]))
59}
60
61fn read_i16_le(bytes: &[u8], off: usize) -> Result<i16, Error> {
62 let end = off
63 .checked_add(2)
64 .ok_or_else(|| Error::Parse("i16 offset overflow".to_string()))?;
65 let slice = bytes
66 .get(off..end)
67 .ok_or_else(|| Error::Parse("unexpected EOF while reading i16".to_string()))?;
68 Ok(i16::from_le_bytes([slice[0], slice[1]]))
69}
70
71fn parse_fnhd(data: &[u8]) -> Result<FntHeader, Error> {
72 if data.len() != 56 {
73 return Err(Error::Parse(format!(
74 "invalid FNHD length: expected 56, got {}",
75 data.len()
76 )));
77 }
78
79 let mut description_raw = [0_u8; 32];
80 description_raw.copy_from_slice(&data[..32]);
81 let nul = description_raw
82 .iter()
83 .position(|b| *b == 0)
84 .unwrap_or(description_raw.len());
85 let description_text = String::from_utf8_lossy(&description_raw[..nul]).to_string();
86
87 Ok(FntHeader {
88 description_raw,
89 description_text,
90 unknown_24: read_u16_le(data, 32)?,
91 has_rdat: read_u16_le(data, 34)?,
92 unknown_28: read_u16_le(data, 36)?,
93 unknown_2a: read_u16_le(data, 38)?,
94 unknown_2c: read_u16_le(data, 40)?,
95 max_width: read_u16_le(data, 42)?,
96 line_height: read_u16_le(data, 44)?,
97 character_start: read_u16_le(data, 46)?,
98 character_count: read_u16_le(data, 48)?,
99 unknown_36: read_u16_le(data, 50)?,
100 unknown_38: read_u16_le(data, 52)?,
101 has_palette: read_u16_le(data, 54)?,
102 })
103}
104
105fn parse_palette(tag: [u8; 4], data: &[u8]) -> Result<FntPalette, Error> {
106 if !data.len().is_multiple_of(3) {
107 return Err(Error::Parse(format!(
108 "invalid palette length {}; must be divisible by 3",
109 data.len()
110 )));
111 }
112
113 let colors = data.as_chunks::<3>().0.to_vec();
114
115 Ok(FntPalette { tag, colors })
116}
117
118fn parse_fbmp(data: &[u8], glyph_count: u16) -> Result<Vec<FntGlyph>, Error> {
119 let mut offset = 0_usize;
120 let mut glyphs = Vec::with_capacity(glyph_count as usize);
121
122 for _ in 0..glyph_count {
123 let header_end = offset
124 .checked_add(10)
125 .ok_or_else(|| Error::Parse("glyph header overflow".to_string()))?;
126 if header_end > data.len() {
127 return Err(Error::Parse("truncated FBMP glyph header".to_string()));
128 }
129
130 let enabled = read_u16_le(data, offset)?;
131 let offset_left = read_i16_le(data, offset + 2)?;
132 let offset_top = read_i16_le(data, offset + 4)?;
133 let width = read_u16_le(data, offset + 6)?;
134 let height = read_u16_le(data, offset + 8)?;
135
136 offset = header_end;
137
138 let pixels_len = usize::from(width)
139 .checked_mul(usize::from(height))
140 .ok_or_else(|| Error::Parse("glyph pixel length overflow".to_string()))?;
141 let pixel_end = offset
142 .checked_add(pixels_len)
143 .ok_or_else(|| Error::Parse("glyph pixel offset overflow".to_string()))?;
144
145 if pixel_end > data.len() {
146 return Err(Error::Parse("truncated FBMP glyph pixels".to_string()));
147 }
148
149 let pixels = data[offset..pixel_end].to_vec();
150 offset = pixel_end;
151
152 glyphs.push(FntGlyph {
153 enabled,
154 offset_left,
155 offset_top,
156 width,
157 height,
158 pixels,
159 });
160 }
161
162 if offset != data.len() {
163 return Err(Error::Parse(format!(
164 "FBMP has {} trailing bytes after parsing {} glyphs",
165 data.len() - offset,
166 glyph_count
167 )));
168 }
169
170 Ok(glyphs)
171}
172
173fn read_chunk_tag(bytes: &[u8], pos: usize) -> Result<[u8; 4], Error> {
174 let tag_end = pos
175 .checked_add(4)
176 .ok_or_else(|| Error::Parse("tag offset overflow".to_string()))?;
177 if tag_end > bytes.len() {
178 return Err(Error::Parse("truncated chunk tag".to_string()));
179 }
180
181 let mut tag = [0_u8; 4];
182 tag.copy_from_slice(&bytes[pos..tag_end]);
183 Ok(tag)
184}
185
186fn read_chunk_data_bounds(bytes: &[u8], tag: [u8; 4], pos: usize) -> Result<(usize, usize), Error> {
187 let len_off = pos
188 .checked_add(4)
189 .ok_or_else(|| Error::Parse("length offset overflow".to_string()))?;
190 let len_end = len_off
191 .checked_add(4)
192 .ok_or_else(|| Error::Parse("length offset overflow".to_string()))?;
193 if len_end > bytes.len() {
194 return Err(Error::Parse("truncated chunk length".to_string()));
195 }
196
197 let chunk_len = usize::try_from(u32::from_be_bytes([
198 bytes[len_off],
199 bytes[len_off + 1],
200 bytes[len_off + 2],
201 bytes[len_off + 3],
202 ]))
203 .map_err(|e| Error::Parse(format!("chunk length does not fit usize: {e}")))?;
204
205 let data_start = len_end;
206 let data_end = data_start
207 .checked_add(chunk_len)
208 .ok_or_else(|| Error::Parse("chunk data offset overflow".to_string()))?;
209 if data_end > bytes.len() {
210 return Err(Error::Parse(format!(
211 "chunk '{}' length {} exceeds file size",
212 String::from_utf8_lossy(&tag),
213 chunk_len
214 )));
215 }
216
217 Ok((data_start, data_end))
218}
219
220fn parse_non_end_chunk(
221 tag: [u8; 4],
222 data: &[u8],
223 header: &mut Option<FntHeader>,
224 palette: &mut Option<FntPalette>,
225 glyphs: &mut Option<Vec<FntGlyph>>,
226 rdat: &mut Option<Vec<u8>>,
227) -> Result<(), Error> {
228 match &tag {
229 b"FNHD" => {
230 if header.is_some() {
231 return Err(Error::Parse("duplicate FNHD chunk".to_string()));
232 }
233 *header = Some(parse_fnhd(data)?);
234 }
235 b"BPAL" | b"FPAL" => {
236 if palette.is_some() {
237 return Err(Error::Parse("duplicate palette chunk".to_string()));
238 }
239 *palette = Some(parse_palette(tag, data)?);
240 }
241 b"FBMP" => {
242 if glyphs.is_some() {
243 return Err(Error::Parse("duplicate FBMP chunk".to_string()));
244 }
245 let hdr = header
246 .as_ref()
247 .ok_or_else(|| Error::Parse("FBMP encountered before FNHD".to_string()))?;
248 *glyphs = Some(parse_fbmp(data, hdr.character_count)?);
249 }
250 b"RDAT" => {
251 if rdat.is_some() {
252 return Err(Error::Parse("duplicate RDAT chunk".to_string()));
253 }
254 *rdat = Some(data.to_vec());
255 }
256 _ => {
257 return Err(Error::Parse(format!(
258 "unsupported FNT chunk tag: '{}'",
259 String::from_utf8_lossy(&tag)
260 )));
261 }
262 }
263
264 Ok(())
265}
266
267fn read_trailing_padding(bytes: &[u8], pos: usize) -> Result<Vec<u8>, Error> {
268 if pos >= bytes.len() {
269 return Ok(Vec::new());
270 }
271
272 let tail = bytes[pos..].to_vec();
273 if !tail.iter().all(|b| *b == 0) {
274 return Err(Error::Parse(
275 "non-zero trailing data after END marker".to_string(),
276 ));
277 }
278
279 Ok(tail)
280}
281
282#[allow(clippy::missing_errors_doc)]
284pub fn parse_fnt(bytes: &[u8]) -> Result<FntFile, Error> {
285 let mut pos = 0_usize;
286
287 let mut chunk_order = Vec::new();
288 let mut header: Option<FntHeader> = None;
289 let mut palette: Option<FntPalette> = None;
290 let mut glyphs: Option<Vec<FntGlyph>> = None;
291 let mut rdat: Option<Vec<u8>> = None;
292
293 while pos < bytes.len() {
294 let tag = read_chunk_tag(bytes, pos)?;
295
296 if tag == *b"END " {
297 chunk_order.push("END".to_string());
298 pos = pos.saturating_add(4);
299 break;
300 }
301
302 let (data_start, data_end) = read_chunk_data_bounds(bytes, tag, pos)?;
303 let data = &bytes[data_start..data_end];
304 chunk_order.push(String::from_utf8_lossy(&tag).to_string());
305 parse_non_end_chunk(tag, data, &mut header, &mut palette, &mut glyphs, &mut rdat)?;
306
307 pos = data_end;
308 }
309
310 let header = header.ok_or_else(|| Error::Parse("missing FNHD chunk".to_string()))?;
311 let palette = palette.ok_or_else(|| Error::Parse("missing BPAL/FPAL chunk".to_string()))?;
312 let glyphs = glyphs.ok_or_else(|| Error::Parse("missing FBMP chunk".to_string()))?;
313
314 if header.has_rdat == 1 && rdat.is_none() {
315 return Err(Error::Parse(
316 "FNHD.HasRDAT is 1 but RDAT chunk is missing".to_string(),
317 ));
318 }
319 if header.has_rdat == 0 && rdat.is_some() {
320 return Err(Error::Parse(
321 "FNHD.HasRDAT is 0 but RDAT chunk is present".to_string(),
322 ));
323 }
324
325 let trailing_padding = read_trailing_padding(bytes, pos)?;
326
327 Ok(FntFile {
328 chunk_order,
329 header,
330 palette,
331 glyphs,
332 rdat,
333 trailing_padding,
334 })
335}