use clap::{Parser, ValueEnum};
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, Clone, Copy, ValueEnum)]
enum Align {
Top,
Center,
Bottom,
}
impl Align {
fn box_yoffset(self, line_height: i32, height: i32) -> i32 {
match self {
Align::Top => 0,
Align::Center => (line_height - height) / 2,
Align::Bottom => line_height - height,
}
}
fn as_str(self) -> &'static str {
match self {
Align::Top => "top",
Align::Center => "center",
Align::Bottom => "bottom",
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum AlignBy {
Auto,
Box,
Ink,
}
#[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,
#[arg(long, value_enum, default_value_t = Align::Bottom)]
align: Align,
#[arg(long, value_enum, default_value_t = AlignBy::Auto)]
align_by: AlignBy,
#[arg(long, default_value_t = 128)]
ink_threshold: u8,
#[arg(long, default_value_t = 1)]
padding: u32,
}
struct GlyphEntry {
path: PathBuf,
id: u32,
ch: char,
width: u32,
height: u32,
ink_top: u32,
ink_bottom: u32,
rgba: RgbaImage,
}
struct Placed {
glyph: GlyphEntry,
x: u32,
y: u32,
yoffset: i32,
}
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 ink_rows(img: &RgbaImage, threshold: u8) -> Option<(u32, u32)> {
let (w, h) = img.dimensions();
let mut top = None;
let mut bottom = 0u32;
for y in 0..h {
let inked = (0..w).any(|x| img.get_pixel(x, y)[3] >= threshold);
if inked {
top.get_or_insert(y);
bottom = y;
}
}
top.map(|t| (t, bottom))
}
fn glyph_ink(img: &RgbaImage, threshold: u8) -> (u32, u32) {
ink_rows(img, threshold)
.or_else(|| ink_rows(img, 1))
.unwrap_or((0, img.height().saturating_sub(1)))
}
fn load_glyph(path: &Path, ink_threshold: u8) -> 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();
let (ink_top, ink_bottom) = glyph_ink(&rgba, ink_threshold);
Ok(GlyphEntry {
path: path.to_path_buf(),
id,
ch,
width,
height,
ink_top,
ink_bottom,
rgba,
})
}
fn ink_yoffsets(entries: &[GlyphEntry], align: Align) -> Vec<i32> {
match align {
Align::Top => {
let anchor = entries.iter().map(|e| e.ink_top).max().unwrap_or(0) as i32;
entries.iter().map(|e| anchor - e.ink_top as i32).collect()
}
Align::Bottom => {
let anchor = entries.iter().map(|e| e.ink_bottom).max().unwrap_or(0) as i32;
entries
.iter()
.map(|e| anchor - e.ink_bottom as i32)
.collect()
}
Align::Center => {
let center = |e: &GlyphEntry| (e.ink_top + e.ink_bottom) as i32;
let anchor = entries.iter().map(center).max().unwrap_or(0);
entries
.iter()
.map(|e| ((anchor - center(e)) as f32 / 2.0).round() as i32)
.collect()
}
}
}
fn shelf_pack(
sizes: &[(u32, u32)],
max_width: u32,
padding: 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 + padding;
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 + padding;
}
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,
padding: u32,
glyphs: &[Placed],
) -> 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={padding},{padding} outline=0\n"
));
lines.push_str(&format!(
"common lineHeight={line_height} base={base} scaleW={atlas_w} scaleH={atlas_h} pages=1 packed=0 alphaChnl=0 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 p in glyphs {
let g = &p.glyph;
let xo = 0i32;
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, p.x, p.y, g.width, g.height, xo, p.yoffset, 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 ink_threshold = cli.ink_threshold.max(1);
let mut seen: HashMap<u32, PathBuf> = HashMap::new();
let mut entries = Vec::new();
for p in &paths {
let g = load_glyph(p, ink_threshold)?;
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, cli.padding)?;
let line_height = entries.iter().map(|e| e.height).max().unwrap_or(1);
let base = line_height;
let uniform_height = entries.iter().all(|e| e.height == line_height);
let use_ink = match cli.align_by {
AlignBy::Box => false,
AlignBy::Ink => true,
AlignBy::Auto => !uniform_height,
};
let yoffsets: Vec<i32> = if use_ink {
ink_yoffsets(&entries, cli.align)
} else {
entries
.iter()
.map(|e| cli.align.box_yoffset(line_height as i32, e.height as i32))
.collect()
};
eprintln!(
"Aligning glyph {} to the {} (source heights {})",
if use_ink { "ink" } else { "boxes" },
cli.align.as_str(),
if uniform_height {
"all equal"
} else {
"differ"
}
);
let mut atlas = RgbaImage::from_pixel(atlas_w, atlas_h, Rgba([0, 0, 0, 0]));
let mut placed: Vec<Placed> = Vec::new();
for ((mut g, &(px, py)), yoffset) in entries
.into_iter()
.zip(placements.iter())
.zip(yoffsets.into_iter())
{
blit(&mut atlas, &g.rgba, px, py);
g.rgba = RgbaImage::new(1, 1);
placed.push(Placed {
glyph: g,
x: px,
y: py,
yoffset,
});
}
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,
cli.padding,
&placed,
)?;
eprintln!("Wrote {}", out_fnt.display());
eprintln!("Wrote {}", out_png.display());
Ok(())
}