bbcat 0.1.12

View ANSI, DIZ, ADF, RIPscrip, and XBin art in terminals or export PNG images
Documentation
use std::sync::OnceLock;

const ENCODED: &str = include_str!("font_data.b64");
const ENCODED_8X8: &str = include_str!("bgi_fonts/cp437_f08.b64");
const AMIGA_MICROKNIGHT: &str = include_str!("fonts/amiga_microknight.b64");
const AMIGA_TOPAZ_2_PLUS: &str = include_str!("fonts/amiga_topaz_2_plus.b64");
const EMPATHY_BY_SKABOY: &str = include_str!("fonts/empathy_by_skaboy.b64");

pub(crate) struct SauceFont {
    pub(crate) glyphs: &'static [u8],
    pub(crate) glyph_height: usize,
}

pub fn glyphs() -> &'static [u8] {
    static GLYPHS: OnceLock<Vec<u8>> = OnceLock::new();
    GLYPHS.get_or_init(|| {
        let bytes = decode(ENCODED.trim());
        assert_eq!(
            bytes.len(),
            256 * 16,
            "embedded VGA font has the wrong size"
        );
        bytes
    })
}

pub(crate) fn glyphs_8x8() -> &'static [u8] {
    static GLYPHS: OnceLock<Vec<u8>> = OnceLock::new();
    GLYPHS.get_or_init(|| {
        let bytes = decode(ENCODED_8X8.trim());
        assert_eq!(
            bytes.len(),
            256 * 8,
            "embedded VGA 8x8 font has the wrong size"
        );
        bytes
    })
}

fn amiga_microknight() -> &'static [u8] {
    static GLYPHS: OnceLock<Vec<u8>> = OnceLock::new();
    GLYPHS.get_or_init(|| decode_8x16(AMIGA_MICROKNIGHT, "Amiga MicroKnight"))
}

fn amiga_topaz_2_plus() -> &'static [u8] {
    static GLYPHS: OnceLock<Vec<u8>> = OnceLock::new();
    GLYPHS.get_or_init(|| decode_8x16(AMIGA_TOPAZ_2_PLUS, "Amiga Topaz 2+"))
}

fn empathy_by_skaboy() -> &'static [u8] {
    static GLYPHS: OnceLock<Vec<u8>> = OnceLock::new();
    GLYPHS.get_or_init(|| decode_8x16(EMPATHY_BY_SKABOY, "Empathy by Skaboy"))
}

fn decode_8x16(encoded: &str, name: &str) -> Vec<u8> {
    let bytes = decode(encoded.trim());
    assert_eq!(
        bytes.len(),
        256 * 16,
        "embedded {name} font has the wrong size"
    );
    bytes
}

pub(crate) fn sauce_font(name: &str) -> Option<SauceFont> {
    let (glyphs, glyph_height): (&[u8], usize) = if name.eq_ignore_ascii_case("IBM VGA50") {
        (glyphs_8x8(), 8)
    } else if name.eq_ignore_ascii_case("Amiga MicroKnight") {
        (amiga_microknight(), 16)
    } else if name.eq_ignore_ascii_case("Amiga Topaz 2+") {
        (amiga_topaz_2_plus(), 16)
    } else if name.eq_ignore_ascii_case("Empathy by Skaboy") {
        (empathy_by_skaboy(), 16)
    } else {
        return None;
    };
    Some(SauceFont {
        glyphs,
        glyph_height,
    })
}

pub(crate) fn decode(input: &str) -> Vec<u8> {
    let encoded: Vec<u8> = input
        .bytes()
        .filter(|byte| !byte.is_ascii_whitespace())
        .collect();
    let mut output = Vec::with_capacity(encoded.len() / 4 * 3);
    for group in encoded.chunks(4) {
        if group.len() != 4 {
            break;
        }
        let value = (u32::from(six_bits(group[0])) << 18)
            | (u32::from(six_bits(group[1])) << 12)
            | (u32::from(six_bits(group[2])) << 6)
            | u32::from(six_bits(group[3]));
        output.push((value >> 16) as u8);
        if group[2] != b'=' {
            output.push((value >> 8) as u8);
        }
        if group[3] != b'=' {
            output.push(value as u8);
        }
    }
    output
}

fn six_bits(byte: u8) -> u8 {
    match byte {
        b'A'..=b'Z' => byte - b'A',
        b'a'..=b'z' => byte - b'a' + 26,
        b'0'..=b'9' => byte - b'0' + 52,
        b'+' => 62,
        b'/' => 63,
        b'=' => 0,
        _ => panic!("invalid base64 in embedded VGA font"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn embedded_font_has_all_cp437_glyphs() {
        assert_eq!(glyphs().len(), 4096);
        assert!(
            glyphs()[0xdb * 16..0xdc * 16]
                .iter()
                .all(|&row| row == 0xff)
        );
        assert_eq!(glyphs_8x8().len(), 2048);
        assert!(
            glyphs_8x8()[0xdb * 8..0xdc * 8]
                .iter()
                .all(|&row| row == 0xff)
        );
    }

    #[test]
    fn sauce_fonts_have_complete_bitmap_tables() {
        for name in ["Amiga MicroKnight", "Amiga Topaz 2+", "Empathy by Skaboy"] {
            let font = sauce_font(name).unwrap();
            assert_eq!(font.glyphs.len(), 4096, "{name}");
            assert_eq!(font.glyph_height, 16, "{name}");
        }
    }

    #[test]
    fn sauce_font_names_are_case_insensitive() {
        assert!(sauce_font("amiga microknight").is_some());
        assert!(sauce_font("AMIGA TOPAZ 2+").is_some());
        assert!(sauce_font("empathy BY skaboy").is_some());
        assert!(sauce_font("unknown").is_none());
    }
}