Skip to main content

rgpre/import/
fnt_export.rs

1use crate::error::Error;
2use crate::import::{fnt, fnt_ttf, png::save_png};
3use image::{DynamicImage, Rgba, RgbaImage};
4use serde_json::json;
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
9/// Output file paths generated by bitmap font export.
10pub struct BitmapExportPaths {
11    pub png_path: PathBuf,
12    pub json_path: PathBuf,
13    pub bmfont_path: PathBuf,
14}
15
16fn ensure_parent_dir(path: &Path) -> Result<(), Error> {
17    if let Some(parent) = path.parent()
18        && !parent.as_os_str().is_empty()
19    {
20        std::fs::create_dir_all(parent).map_err(|e| Error::File {
21            path: parent.to_path_buf(),
22            message: format!("failed to create directory: {e}"),
23        })?;
24    }
25    Ok(())
26}
27
28fn paired_output_paths(output: &Path) -> (PathBuf, PathBuf) {
29    let ext = output
30        .extension()
31        .and_then(|e| e.to_str())
32        .map(str::to_ascii_lowercase);
33
34    match ext.as_deref() {
35        Some("png") => (output.to_path_buf(), output.with_extension("json")),
36        Some("json") => (output.with_extension("png"), output.to_path_buf()),
37        Some(_) => (output.with_extension("png"), output.with_extension("json")),
38        None => {
39            let mut png = output.to_path_buf();
40            png.set_extension("png");
41            let mut json_path = output.to_path_buf();
42            json_path.set_extension("json");
43            (png, json_path)
44        }
45    }
46}
47
48fn ceil_sqrt(value: u32) -> u32 {
49    let mut cols = 1_u32;
50    while cols.saturating_mul(cols) < value.max(1) {
51        cols = cols.saturating_add(1);
52    }
53    cols
54}
55
56fn sheet_layout(glyphs: &[fnt::FntGlyph]) -> Result<(u32, u32, u32, u32, u32, u32), Error> {
57    let glyph_count = u32::try_from(glyphs.len())
58        .map_err(|e| Error::Conversion(format!("glyph count does not fit in u32: {e}")))?;
59    let max_width = glyphs
60        .iter()
61        .map(|glyph| u32::from(glyph.width))
62        .max()
63        .unwrap_or(1);
64    let max_height = glyphs
65        .iter()
66        .map(|glyph| u32::from(glyph.height))
67        .max()
68        .unwrap_or(1);
69
70    let cell_w = max_width.saturating_add(2);
71    let cell_h = max_height.saturating_add(2);
72    let cols = ceil_sqrt(glyph_count);
73    let rows = glyph_count.max(1).div_ceil(cols);
74    let sheet_w = cols.saturating_mul(cell_w).max(1);
75    let sheet_h = rows.saturating_mul(cell_h).max(1);
76
77    Ok((cell_w, cell_h, cols, rows, sheet_w, sheet_h))
78}
79
80fn clear_image(image: &mut RgbaImage) {
81    for px in image.pixels_mut() {
82        *px = Rgba([0, 0, 0, 0]);
83    }
84}
85
86fn palette_rgba(fnt: &fnt::FntFile, pal_idx: usize) -> [u8; 4] {
87    let color = fnt
88        .palette
89        .colors
90        .get(pal_idx)
91        .copied()
92        .unwrap_or([0_u8, 0_u8, 0_u8]);
93
94    if pal_idx == 0 {
95        [0, 0, 0, 0]
96    } else {
97        [
98            color[0].saturating_mul(4),
99            color[1].saturating_mul(4),
100            color[2].saturating_mul(4),
101            255,
102        ]
103    }
104}
105
106fn draw_glyph(
107    image: &mut RgbaImage,
108    fnt: &fnt::FntFile,
109    glyph: &fnt::FntGlyph,
110    origin_x: u32,
111    origin_y: u32,
112) {
113    for y in 0..u32::from(glyph.height) {
114        for x in 0..u32::from(glyph.width) {
115            let pix_idx = usize::try_from(y)
116                .unwrap_or(0)
117                .saturating_mul(usize::from(glyph.width))
118                .saturating_add(usize::try_from(x).unwrap_or(0));
119            let pal_idx = usize::from(*glyph.pixels.get(pix_idx).unwrap_or(&0));
120            image.put_pixel(origin_x + x, origin_y + y, Rgba(palette_rgba(fnt, pal_idx)));
121        }
122    }
123}
124
125fn glyph_metadata_entry(
126    idx: usize,
127    codepoint: u32,
128    glyph: &fnt::FntGlyph,
129    origin_x: u32,
130    origin_y: u32,
131) -> serde_json::Value {
132    let ch = char::from_u32(codepoint).unwrap_or('?');
133    json!({
134        "index": idx,
135        "codepoint": codepoint,
136        "char": ch.to_string(),
137        "enabled": glyph.enabled,
138        "offset_left": glyph.offset_left,
139        "offset_top": glyph.offset_top,
140        "width": glyph.width,
141        "height": glyph.height,
142        "sheet_x": origin_x,
143        "sheet_y": origin_y
144    })
145}
146
147fn build_glyph_atlas(
148    fnt: &fnt::FntFile,
149    image: &mut RgbaImage,
150    cell_w: u32,
151    cell_h: u32,
152    cols: u32,
153) -> Result<Vec<serde_json::Value>, Error> {
154    let mut glyph_meta = Vec::with_capacity(fnt.glyphs.len());
155
156    for (idx, glyph) in fnt.glyphs.iter().enumerate() {
157        let glyph_index = u32::try_from(idx)
158            .map_err(|e| Error::Conversion(format!("glyph index does not fit in u32: {e}")))?;
159        let col = glyph_index % cols;
160        let row = glyph_index / cols;
161        let origin_x = col.saturating_mul(cell_w).saturating_add(1);
162        let origin_y = row.saturating_mul(cell_h).saturating_add(1);
163        let codepoint = u32::from(fnt.header.character_start).saturating_add(glyph_index);
164
165        draw_glyph(image, fnt, glyph, origin_x, origin_y);
166        glyph_meta.push(glyph_metadata_entry(
167            idx, codepoint, glyph, origin_x, origin_y,
168        ));
169    }
170
171    Ok(glyph_meta)
172}
173
174fn build_description_raw_hex(description_raw: &[u8; 32]) -> String {
175    let mut hex = String::with_capacity(description_raw.len() * 2);
176    for byte in description_raw {
177        let _ = write!(hex, "{byte:02x}");
178    }
179    hex
180}
181
182fn write_bmfont_text(
183    face_name: &str,
184    png_path: &Path,
185    fnt_path: &Path,
186    line_height: u16,
187    sheet_w: u32,
188    sheet_h: u32,
189    glyph_entries: &[serde_json::Value],
190) -> Result<(), Error> {
191    let page_file = png_path
192        .file_name()
193        .and_then(|n| n.to_str())
194        .unwrap_or("atlas.png");
195
196    let mut text = String::new();
197    writeln!(
198        text,
199        "info face=\"{face_name}\" size={line_height} bold=0 italic=0 charset=\"\" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=1,1 outline=0"
200    )
201    .map_err(|e| Error::Conversion(format!("failed to compose BMFont info line: {e}")))?;
202    writeln!(
203        text,
204        "common lineHeight={line_height} base={line_height} scaleW={sheet_w} scaleH={sheet_h} pages=1 packed=0 alphaChnl=0 redChnl=0 greenChnl=0 blueChnl=0"
205    )
206    .map_err(|e| Error::Conversion(format!("failed to compose BMFont common line: {e}")))?;
207    writeln!(text, "page id=0 file=\"{page_file}\"")
208        .map_err(|e| Error::Conversion(format!("failed to compose BMFont page line: {e}")))?;
209    text.push_str("chars count=");
210    text.push_str(&glyph_entries.len().to_string());
211    text.push('\n');
212
213    for glyph in glyph_entries {
214        let codepoint = glyph
215            .get("codepoint")
216            .and_then(serde_json::Value::as_u64)
217            .unwrap_or(0);
218        let x = glyph
219            .get("sheet_x")
220            .and_then(serde_json::Value::as_u64)
221            .unwrap_or(0);
222        let y = glyph
223            .get("sheet_y")
224            .and_then(serde_json::Value::as_u64)
225            .unwrap_or(0);
226        let width = glyph
227            .get("width")
228            .and_then(serde_json::Value::as_u64)
229            .unwrap_or(0);
230        let height = glyph
231            .get("height")
232            .and_then(serde_json::Value::as_u64)
233            .unwrap_or(0);
234        let xoffset = glyph
235            .get("offset_left")
236            .and_then(serde_json::Value::as_i64)
237            .unwrap_or(0);
238        let yoffset = glyph
239            .get("offset_top")
240            .and_then(serde_json::Value::as_i64)
241            .unwrap_or(0);
242        let xadvance = width.saturating_add(1);
243
244        writeln!(
245            text,
246            "char id={codepoint} x={x} y={y} width={width} height={height} xoffset={xoffset} yoffset={yoffset} xadvance={xadvance} page=0 chnl=15"
247        )
248        .map_err(|e| Error::Conversion(format!("failed to compose BMFont char line: {e}")))?;
249    }
250
251    std::fs::write(fnt_path, text)?;
252    Ok(())
253}
254
255/// Converts an input FNT file to a TTF file at `output_path`.
256#[allow(clippy::missing_errors_doc)]
257pub fn export_fnt_ttf(input_path: &Path, output_path: &Path) -> Result<(), Error> {
258    let bytes = std::fs::read(input_path)?;
259    let parsed = fnt::parse_fnt(&bytes)?;
260
261    let family_name = input_path
262        .file_stem()
263        .and_then(|s| s.to_str())
264        .unwrap_or("RedguardFnt");
265
266    let ttf_bytes = fnt_ttf::build_ttf_from_fnt(&parsed, family_name)?;
267
268    ensure_parent_dir(output_path)?;
269    std::fs::write(output_path, ttf_bytes)?;
270
271    Ok(())
272}
273
274/// Exports an input FNT file as atlas image plus `JSON` and `BMFont` metadata.
275#[allow(clippy::missing_errors_doc)]
276pub fn export_fnt_bitmap(
277    input_path: &Path,
278    output_path: &Path,
279    compress: bool,
280) -> Result<BitmapExportPaths, Error> {
281    let bytes = std::fs::read(input_path)?;
282    let fnt = fnt::parse_fnt(&bytes)?;
283    let (cell_w, cell_h, cols, rows, sheet_w, sheet_h) = sheet_layout(&fnt.glyphs)?;
284    let mut image = RgbaImage::new(sheet_w, sheet_h);
285    clear_image(&mut image);
286    let glyph_meta = build_glyph_atlas(&fnt, &mut image, cell_w, cell_h, cols)?;
287
288    let (png_path, json_path) = paired_output_paths(output_path);
289    let bmfont_path = json_path.with_extension("fnt");
290    ensure_parent_dir(&png_path)?;
291    ensure_parent_dir(&json_path)?;
292    ensure_parent_dir(&bmfont_path)?;
293    save_png(&DynamicImage::ImageRgba8(image), &png_path, compress)
294        .map_err(|e| Error::Conversion(e.to_string()))?;
295
296    let face_name = input_path
297        .file_stem()
298        .and_then(|s| s.to_str())
299        .unwrap_or("RedguardFnt");
300    write_bmfont_text(
301        face_name,
302        &png_path,
303        &bmfont_path,
304        fnt.header.line_height,
305        sheet_w,
306        sheet_h,
307        &glyph_meta,
308    )?;
309
310    let metadata = json!({
311        "format": "redguard-fnt-export-v1",
312        "source_file": input_path,
313        "chunk_order": fnt.chunk_order,
314        "has_rdat": fnt.rdat.is_some(),
315        "trailing_padding_bytes": fnt.trailing_padding.len(),
316        "palette_tag": String::from_utf8_lossy(&fnt.palette.tag).to_string(),
317        "palette_size": fnt.palette.colors.len(),
318        "sheet": {
319            "path": png_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
320            "width": sheet_w,
321            "height": sheet_h,
322            "cell_width": cell_w,
323            "cell_height": cell_h,
324            "columns": cols,
325            "rows": rows
326        },
327        "header": {
328            "description_text": fnt.header.description_text,
329            "description_raw_hex": build_description_raw_hex(&fnt.header.description_raw),
330            "unknown_24": fnt.header.unknown_24,
331            "has_rdat": fnt.header.has_rdat,
332            "unknown_28": fnt.header.unknown_28,
333            "unknown_2a": fnt.header.unknown_2a,
334            "unknown_2c": fnt.header.unknown_2c,
335            "max_width": fnt.header.max_width,
336            "line_height": fnt.header.line_height,
337            "character_start": fnt.header.character_start,
338            "character_count": fnt.header.character_count,
339            "unknown_36": fnt.header.unknown_36,
340            "unknown_38": fnt.header.unknown_38,
341            "has_palette": fnt.header.has_palette
342        },
343        "glyphs": glyph_meta
344    });
345
346    let metadata_json = serde_json::to_string_pretty(&metadata)?;
347    std::fs::write(&json_path, metadata_json)?;
348
349    Ok(BitmapExportPaths {
350        png_path,
351        json_path,
352        bmfont_path,
353    })
354}