Skip to main content

rgpre/import/
fnt.rs

1use crate::error::Error;
2
3#[derive(Debug, Clone)]
4/// Parsed FNT file with chunk order, metadata, palette, and glyph bitmap data.
5pub 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)]
15/// Parsed `FNHD` chunk fields from a Redguard FNT file.
16pub 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)]
34/// Parsed palette chunk (`BPAL` or `FPAL`) from a FNT file.
35pub struct FntPalette {
36    pub tag: [u8; 4],
37    pub colors: Vec<[u8; 3]>,
38}
39
40#[derive(Debug, Clone)]
41/// Parsed glyph bitmap record from the `FBMP` chunk.
42pub 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/// Parses a Redguard FNT file from raw bytes.
283#[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}