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)]
9pub 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#[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#[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}