redguard-preservation 0.6.1

A CLI tool to preserve Redguard files
Documentation
use crate::error::Error;
use crate::import::{fnt, fnt_ttf, png::save_png};
use image::{DynamicImage, Rgba, RgbaImage};
use serde_json::json;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
/// Output file paths generated by bitmap font export.
pub struct BitmapExportPaths {
    pub png_path: PathBuf,
    pub json_path: PathBuf,
    pub bmfont_path: PathBuf,
}

fn ensure_parent_dir(path: &Path) -> Result<(), Error> {
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        std::fs::create_dir_all(parent).map_err(|e| Error::File {
            path: parent.to_path_buf(),
            message: format!("failed to create directory: {e}"),
        })?;
    }
    Ok(())
}

fn paired_output_paths(output: &Path) -> (PathBuf, PathBuf) {
    let ext = output
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_ascii_lowercase);

    match ext.as_deref() {
        Some("png") => (output.to_path_buf(), output.with_extension("json")),
        Some("json") => (output.with_extension("png"), output.to_path_buf()),
        Some(_) => (output.with_extension("png"), output.with_extension("json")),
        None => {
            let mut png = output.to_path_buf();
            png.set_extension("png");
            let mut json_path = output.to_path_buf();
            json_path.set_extension("json");
            (png, json_path)
        }
    }
}

fn ceil_sqrt(value: u32) -> u32 {
    let mut cols = 1_u32;
    while cols.saturating_mul(cols) < value.max(1) {
        cols = cols.saturating_add(1);
    }
    cols
}

fn sheet_layout(glyphs: &[fnt::FntGlyph]) -> Result<(u32, u32, u32, u32, u32, u32), Error> {
    let glyph_count = u32::try_from(glyphs.len())
        .map_err(|e| Error::Conversion(format!("glyph count does not fit in u32: {e}")))?;
    let max_width = glyphs
        .iter()
        .map(|glyph| u32::from(glyph.width))
        .max()
        .unwrap_or(1);
    let max_height = glyphs
        .iter()
        .map(|glyph| u32::from(glyph.height))
        .max()
        .unwrap_or(1);

    let cell_w = max_width.saturating_add(2);
    let cell_h = max_height.saturating_add(2);
    let cols = ceil_sqrt(glyph_count);
    let rows = glyph_count.max(1).div_ceil(cols);
    let sheet_w = cols.saturating_mul(cell_w).max(1);
    let sheet_h = rows.saturating_mul(cell_h).max(1);

    Ok((cell_w, cell_h, cols, rows, sheet_w, sheet_h))
}

fn clear_image(image: &mut RgbaImage) {
    for px in image.pixels_mut() {
        *px = Rgba([0, 0, 0, 0]);
    }
}

fn palette_rgba(fnt: &fnt::FntFile, pal_idx: usize) -> [u8; 4] {
    let color = fnt
        .palette
        .colors
        .get(pal_idx)
        .copied()
        .unwrap_or([0_u8, 0_u8, 0_u8]);

    if pal_idx == 0 {
        [0, 0, 0, 0]
    } else {
        [
            color[0].saturating_mul(4),
            color[1].saturating_mul(4),
            color[2].saturating_mul(4),
            255,
        ]
    }
}

fn draw_glyph(
    image: &mut RgbaImage,
    fnt: &fnt::FntFile,
    glyph: &fnt::FntGlyph,
    origin_x: u32,
    origin_y: u32,
) {
    for y in 0..u32::from(glyph.height) {
        for x in 0..u32::from(glyph.width) {
            let pix_idx = usize::try_from(y)
                .unwrap_or(0)
                .saturating_mul(usize::from(glyph.width))
                .saturating_add(usize::try_from(x).unwrap_or(0));
            let pal_idx = usize::from(*glyph.pixels.get(pix_idx).unwrap_or(&0));
            image.put_pixel(origin_x + x, origin_y + y, Rgba(palette_rgba(fnt, pal_idx)));
        }
    }
}

fn glyph_metadata_entry(
    idx: usize,
    codepoint: u32,
    glyph: &fnt::FntGlyph,
    origin_x: u32,
    origin_y: u32,
) -> serde_json::Value {
    let ch = char::from_u32(codepoint).unwrap_or('?');
    json!({
        "index": idx,
        "codepoint": codepoint,
        "char": ch.to_string(),
        "enabled": glyph.enabled,
        "offset_left": glyph.offset_left,
        "offset_top": glyph.offset_top,
        "width": glyph.width,
        "height": glyph.height,
        "sheet_x": origin_x,
        "sheet_y": origin_y
    })
}

fn build_glyph_atlas(
    fnt: &fnt::FntFile,
    image: &mut RgbaImage,
    cell_w: u32,
    cell_h: u32,
    cols: u32,
) -> Result<Vec<serde_json::Value>, Error> {
    let mut glyph_meta = Vec::with_capacity(fnt.glyphs.len());

    for (idx, glyph) in fnt.glyphs.iter().enumerate() {
        let glyph_index = u32::try_from(idx)
            .map_err(|e| Error::Conversion(format!("glyph index does not fit in u32: {e}")))?;
        let col = glyph_index % cols;
        let row = glyph_index / cols;
        let origin_x = col.saturating_mul(cell_w).saturating_add(1);
        let origin_y = row.saturating_mul(cell_h).saturating_add(1);
        let codepoint = u32::from(fnt.header.character_start).saturating_add(glyph_index);

        draw_glyph(image, fnt, glyph, origin_x, origin_y);
        glyph_meta.push(glyph_metadata_entry(
            idx, codepoint, glyph, origin_x, origin_y,
        ));
    }

    Ok(glyph_meta)
}

fn build_description_raw_hex(description_raw: &[u8; 32]) -> String {
    let mut hex = String::with_capacity(description_raw.len() * 2);
    for byte in description_raw {
        let _ = write!(hex, "{byte:02x}");
    }
    hex
}

fn write_bmfont_text(
    face_name: &str,
    png_path: &Path,
    fnt_path: &Path,
    line_height: u16,
    sheet_w: u32,
    sheet_h: u32,
    glyph_entries: &[serde_json::Value],
) -> Result<(), Error> {
    let page_file = png_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("atlas.png");

    let mut text = String::new();
    writeln!(
        text,
        "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"
    )
    .map_err(|e| Error::Conversion(format!("failed to compose BMFont info line: {e}")))?;
    writeln!(
        text,
        "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"
    )
    .map_err(|e| Error::Conversion(format!("failed to compose BMFont common line: {e}")))?;
    writeln!(text, "page id=0 file=\"{page_file}\"")
        .map_err(|e| Error::Conversion(format!("failed to compose BMFont page line: {e}")))?;
    text.push_str("chars count=");
    text.push_str(&glyph_entries.len().to_string());
    text.push('\n');

    for glyph in glyph_entries {
        let codepoint = glyph
            .get("codepoint")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let x = glyph
            .get("sheet_x")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let y = glyph
            .get("sheet_y")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let width = glyph
            .get("width")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let height = glyph
            .get("height")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let xoffset = glyph
            .get("offset_left")
            .and_then(serde_json::Value::as_i64)
            .unwrap_or(0);
        let yoffset = glyph
            .get("offset_top")
            .and_then(serde_json::Value::as_i64)
            .unwrap_or(0);
        let xadvance = width.saturating_add(1);

        writeln!(
            text,
            "char id={codepoint} x={x} y={y} width={width} height={height} xoffset={xoffset} yoffset={yoffset} xadvance={xadvance} page=0 chnl=15"
        )
        .map_err(|e| Error::Conversion(format!("failed to compose BMFont char line: {e}")))?;
    }

    std::fs::write(fnt_path, text)?;
    Ok(())
}

/// Converts an input FNT file to a TTF file at `output_path`.
#[allow(clippy::missing_errors_doc)]
pub fn export_fnt_ttf(input_path: &Path, output_path: &Path) -> Result<(), Error> {
    let bytes = std::fs::read(input_path)?;
    let parsed = fnt::parse_fnt(&bytes)?;

    let family_name = input_path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("RedguardFnt");

    let ttf_bytes = fnt_ttf::build_ttf_from_fnt(&parsed, family_name)?;

    ensure_parent_dir(output_path)?;
    std::fs::write(output_path, ttf_bytes)?;

    Ok(())
}

/// Exports an input FNT file as atlas image plus `JSON` and `BMFont` metadata.
#[allow(clippy::missing_errors_doc)]
pub fn export_fnt_bitmap(
    input_path: &Path,
    output_path: &Path,
    compress: bool,
) -> Result<BitmapExportPaths, Error> {
    let bytes = std::fs::read(input_path)?;
    let fnt = fnt::parse_fnt(&bytes)?;
    let (cell_w, cell_h, cols, rows, sheet_w, sheet_h) = sheet_layout(&fnt.glyphs)?;
    let mut image = RgbaImage::new(sheet_w, sheet_h);
    clear_image(&mut image);
    let glyph_meta = build_glyph_atlas(&fnt, &mut image, cell_w, cell_h, cols)?;

    let (png_path, json_path) = paired_output_paths(output_path);
    let bmfont_path = json_path.with_extension("fnt");
    ensure_parent_dir(&png_path)?;
    ensure_parent_dir(&json_path)?;
    ensure_parent_dir(&bmfont_path)?;
    save_png(&DynamicImage::ImageRgba8(image), &png_path, compress)
        .map_err(|e| Error::Conversion(e.to_string()))?;

    let face_name = input_path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("RedguardFnt");
    write_bmfont_text(
        face_name,
        &png_path,
        &bmfont_path,
        fnt.header.line_height,
        sheet_w,
        sheet_h,
        &glyph_meta,
    )?;

    let metadata = json!({
        "format": "redguard-fnt-export-v1",
        "source_file": input_path,
        "chunk_order": fnt.chunk_order,
        "has_rdat": fnt.rdat.is_some(),
        "trailing_padding_bytes": fnt.trailing_padding.len(),
        "palette_tag": String::from_utf8_lossy(&fnt.palette.tag).to_string(),
        "palette_size": fnt.palette.colors.len(),
        "sheet": {
            "path": png_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
            "width": sheet_w,
            "height": sheet_h,
            "cell_width": cell_w,
            "cell_height": cell_h,
            "columns": cols,
            "rows": rows
        },
        "header": {
            "description_text": fnt.header.description_text,
            "description_raw_hex": build_description_raw_hex(&fnt.header.description_raw),
            "unknown_24": fnt.header.unknown_24,
            "has_rdat": fnt.header.has_rdat,
            "unknown_28": fnt.header.unknown_28,
            "unknown_2a": fnt.header.unknown_2a,
            "unknown_2c": fnt.header.unknown_2c,
            "max_width": fnt.header.max_width,
            "line_height": fnt.header.line_height,
            "character_start": fnt.header.character_start,
            "character_count": fnt.header.character_count,
            "unknown_36": fnt.header.unknown_36,
            "unknown_38": fnt.header.unknown_38,
            "has_palette": fnt.header.has_palette
        },
        "glyphs": glyph_meta
    });

    let metadata_json = serde_json::to_string_pretty(&metadata)?;
    std::fs::write(&json_path, metadata_json)?;

    Ok(BitmapExportPaths {
        png_path,
        json_path,
        bmfont_path,
    })
}