gsfnt 0.1.0

Pack a folder of images into BMFont (.fnt) and a merged PNG atlas
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 {
    /// Directory containing source images
    #[arg(short, long)]
    input: PathBuf,

    /// Output path prefix (no extension); writes <prefix>.fnt and <prefix>.png
    #[arg(short, long)]
    output: PathBuf,

    /// Maximum atlas row width in pixels (shelf packing)
    #[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)
}

/// Maps the first Unicode scalar in the filename stem (without extension) to the glyph id.
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,
    })
}

/// Shelf packing: returns each rect's (x, y) in atlas space and the atlas size.
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(())
}