use clap::Parser;
use image::{open, ImageFormat, Rgba, RgbaImage};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("input path is not a directory or is unreadable: {0}")]
InputDir(PathBuf),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("failed to decode image ({path}): {source}")]
ImageDecode {
path: PathBuf,
source: image::ImageError,
},
#[error("failed to encode image: {0}")]
ImageEncode(#[from] image::ImageError),
#[error("filename stem `{stem}` has no usable character: {path}")]
NoChar { stem: String, path: PathBuf },
#[error("duplicate character U+{id:04X} ({ch}):\n {a}\n {b}")]
DuplicateChar {
id: u32,
ch: char,
a: PathBuf,
b: PathBuf,
},
#[error("no supported images in directory (supported: {exts})")]
NoImages { exts: String },
#[error("image width {w} exceeds --max-width {max}: {path}")]
TooWide {
path: PathBuf,
w: u32,
max: u32,
},
#[error("packing failed: glyph width {w} exceeds --max-width {max}")]
PackTooWide { w: u32, max: u32 },
}
#[derive(Parser, Debug)]
#[command(
name = "gsfnt",
about = "Pack images in a directory into a BMFont (.fnt + merged PNG); glyph code point from the first character of each filename stem"
)]
struct Cli {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, default_value_t = 4096)]
max_width: u32,
}
struct GlyphEntry {
path: PathBuf,
id: u32,
ch: char,
width: u32,
height: u32,
rgba: RgbaImage,
}
fn is_image_file(path: &Path) -> bool {
path
.extension()
.and_then(|e| e.to_str())
.map(|e| {
let e = e.to_ascii_lowercase();
IMAGE_EXTS.iter().any(|&ext| ext == e)
})
.unwrap_or(false)
}
fn collect_images(dir: &Path) -> Result<Vec<PathBuf>, AppError> {
if !dir.is_dir() {
return Err(AppError::InputDir(dir.to_path_buf()));
}
let mut paths: Vec<PathBuf> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && is_image_file(p))
.collect();
paths.sort_by(|a, b| {
a.file_name()
.unwrap_or_default()
.cmp(b.file_name().unwrap_or_default())
});
Ok(paths)
}
fn stem_char(path: &Path) -> Result<(char, u32), AppError> {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let ch = stem.chars().next().ok_or_else(|| AppError::NoChar {
stem: stem.to_string(),
path: path.to_path_buf(),
})?;
Ok((ch, ch as u32))
}
fn load_glyph(path: &Path) -> Result<GlyphEntry, AppError> {
let (ch, id) = stem_char(path)?;
let img = open(path).map_err(|source| AppError::ImageDecode {
path: path.to_path_buf(),
source,
})?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
Ok(GlyphEntry {
path: path.to_path_buf(),
id,
ch,
width,
height,
rgba,
})
}
fn shelf_pack(sizes: &[(u32, u32)], max_width: u32) -> Result<(Vec<(u32, u32)>, u32, u32), AppError> {
let mut placements = Vec::with_capacity(sizes.len());
let mut x = 0u32;
let mut y = 0u32;
let mut row_h = 0u32;
let mut atlas_w = 0u32;
let mut atlas_h = 0u32;
for &(w, h) in sizes {
if w > max_width {
return Err(AppError::PackTooWide { w, max: max_width });
}
if x > 0 && x + w > max_width {
y += row_h;
row_h = 0;
x = 0;
}
placements.push((x, y));
row_h = row_h.max(h);
atlas_w = atlas_w.max(x + w);
atlas_h = atlas_h.max(y + h);
x += w;
}
atlas_h = atlas_h.max(y + row_h);
Ok((placements, atlas_w, atlas_h))
}
fn blit(dst: &mut RgbaImage, src: &RgbaImage, dx: u32, dy: u32) {
for (sx, sy, p) in src.enumerate_pixels() {
dst.put_pixel(dx + sx, dy + sy, *p);
}
}
fn write_fnt(
path: &Path,
face: &str,
png_file_name: &str,
line_height: i32,
base: i32,
atlas_w: u32,
atlas_h: u32,
glyphs: &[(GlyphEntry, u32, u32)],
) -> Result<(), AppError> {
let mut lines = String::new();
lines.push_str(&format!(
"info face=\"{face}\" 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\n"
));
lines.push_str(&format!(
"common lineHeight={line_height} base={base} scaleW={atlas_w} scaleH={atlas_h} pages=1 packed=0 alphaChnl=1 redChnl=0 greenChnl=0 blueChnl=0\n"
));
lines.push_str(&format!("page id=0 file=\"{png_file_name}\"\n"));
lines.push_str(&format!("chars count={}\n", glyphs.len()));
for (g, px, py) in glyphs {
let xo = 0i32;
let yo = (line_height as i32) - (g.height as i32);
let xa = g.width as i32;
lines.push_str(&format!(
"char id={} x={} y={} width={} height={} xoffset={} yoffset={} xadvance={} page=0 chnl=15\n",
g.id, px, py, g.width, g.height, xo, yo, xa
));
}
lines.push_str("kernings count=0\n");
fs::write(path, lines)?;
Ok(())
}
fn main() -> Result<(), AppError> {
let cli = Cli::parse();
let paths = collect_images(&cli.input)?;
if paths.is_empty() {
return Err(AppError::NoImages {
exts: IMAGE_EXTS.join(", "),
});
}
let mut seen: HashMap<u32, PathBuf> = HashMap::new();
let mut entries = Vec::new();
for p in &paths {
let g = load_glyph(p)?;
if let Some(prev) = seen.insert(g.id, p.clone()) {
return Err(AppError::DuplicateChar {
id: g.id,
ch: g.ch,
a: prev,
b: p.clone(),
});
}
if g.width > cli.max_width {
return Err(AppError::TooWide {
path: g.path.clone(),
w: g.width,
max: cli.max_width,
});
}
entries.push(g);
}
let sizes: Vec<(u32, u32)> = entries.iter().map(|e| (e.width, e.height)).collect();
let (placements, atlas_w, atlas_h) = shelf_pack(&sizes, cli.max_width)?;
let line_height = entries.iter().map(|e| e.height).max().unwrap_or(1);
let base = line_height;
let mut atlas = RgbaImage::from_pixel(atlas_w, atlas_h, Rgba([0, 0, 0, 0]));
let mut placed: Vec<(GlyphEntry, u32, u32)> = Vec::new();
for (mut g, &(px, py)) in entries.into_iter().zip(placements.iter()) {
blit(&mut atlas, &g.rgba, px, py);
g.rgba = RgbaImage::new(1, 1);
placed.push((g, px, py));
}
let out_fnt = if cli.output.extension().is_some_and(|e| e.eq_ignore_ascii_case("fnt")) {
cli.output.clone()
} else {
cli.output.with_extension("fnt")
};
let out_png = out_fnt.with_extension("png");
let png_file_name = out_png
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("atlas.png")
.to_string();
if let Some(parent) = out_fnt.parent() {
fs::create_dir_all(parent)?;
}
atlas.save_with_format(&out_png, ImageFormat::Png)?;
let face = out_fnt
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("font");
write_fnt(
&out_fnt,
face,
&png_file_name,
line_height as i32,
base as i32,
atlas_w,
atlas_h,
&placed,
)?;
eprintln!("Wrote {}", out_fnt.display());
eprintln!("Wrote {}", out_png.display());
Ok(())
}