bbcat 0.1.12

View ANSI, DIZ, ADF, RIPscrip, and XBin art in terminals or export PNG images
Documentation
use crate::ansi::{Cell, MAX_CELLS, Screen};

const VERSION_LENGTH: usize = 1;
const PALETTE_LENGTH: usize = 64 * 3;
const FONT_LENGTH: usize = 256 * 16;
const HEADER_LENGTH: usize = VERSION_LENGTH + PALETTE_LENGTH + FONT_LENGTH;
const WIDTH: usize = 80;
const ROW_LENGTH: usize = WIDTH * 2;
const COLOR_INDICES: [usize; 16] = [0, 1, 2, 3, 4, 5, 20, 7, 56, 57, 58, 59, 60, 61, 62, 63];

pub fn is_adf(data: &[u8]) -> bool {
    data.len() >= HEADER_LENGTH + ROW_LENGTH
        && data[0] == 1
        && data[1..VERSION_LENGTH + PALETTE_LENGTH]
            .iter()
            .all(|&component| component <= 63)
        && (data.len() - HEADER_LENGTH).is_multiple_of(ROW_LENGTH)
}

pub fn parse(data: &[u8], width_override: Option<usize>) -> Result<Screen, String> {
    if data.len() < HEADER_LENGTH {
        return Err("truncated ADF header".to_owned());
    }
    if data[0] != 1 {
        return Err(format!("unsupported ADF version {}", data[0]));
    }
    if let Some(width) = width_override
        && width != WIDTH
    {
        return Err(format!(
            "width override {width} does not match the ADF width {WIDTH}"
        ));
    }

    let palette_data = &data[VERSION_LENGTH..VERSION_LENGTH + PALETTE_LENGTH];
    if let Some(&component) = palette_data.iter().find(|&&component| component > 63) {
        return Err(format!(
            "invalid ADF palette component {component}; expected a 6-bit value"
        ));
    }
    let mut palette = [[0_u8; 3]; 16];
    for (color, &index) in palette.iter_mut().zip(&COLOR_INDICES) {
        let offset = index * 3;
        for (output, &component) in color.iter_mut().zip(&palette_data[offset..offset + 3]) {
            *output = (component << 2) | (component >> 4);
        }
    }

    let font_start = VERSION_LENGTH + PALETTE_LENGTH;
    let font = data[font_start..HEADER_LENGTH].to_vec();
    let utf8_supported = font.as_slice() == crate::font::glyphs();
    let screen_data = &data[HEADER_LENGTH..];
    if screen_data.is_empty() {
        return Err("ADF contains no screen data".to_owned());
    }
    if !screen_data.len().is_multiple_of(ROW_LENGTH) {
        return Err(format!(
            "invalid ADF screen data length {}; expected complete 80-column rows",
            screen_data.len()
        ));
    }
    let cell_count = screen_data.len() / 2;
    if cell_count > MAX_CELLS {
        return Err(format!(
            "ADF canvas exceeds the {MAX_CELLS} cell safety limit"
        ));
    }
    let cells = screen_data
        .chunks_exact(2)
        .map(|pair| Cell {
            character: u16::from(pair[0]),
            foreground: pair[1] & 0x0f,
            background: pair[1] >> 4,
        })
        .collect();

    Ok(Screen {
        width: WIDTH,
        height: cell_count / WIDTH,
        cells,
        glyph_height: 16,
        font: Some(font),
        palette: Some(palette),
        utf8_supported,
        raster: None,
    })
}

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

    fn fixture() -> Vec<u8> {
        let mut data = vec![0_u8; HEADER_LENGTH + ROW_LENGTH];
        data[0] = 1;
        data[1 + 63 * 3..1 + 64 * 3].copy_from_slice(&[63, 42, 21]);
        data[HEADER_LENGTH] = 0xdb;
        data[HEADER_LENGTH + 1] = 0xf1;
        data
    }

    #[test]
    fn decodes_palette_font_and_cells() {
        let data = fixture();
        assert!(is_adf(&data));
        let screen = parse(&data, None).unwrap();
        assert_eq!((screen.width, screen.height), (80, 1));
        assert_eq!(screen.font.as_ref().unwrap().len(), FONT_LENGTH);
        assert!(!screen.utf8_supported);
        assert_eq!(screen.palette.unwrap()[15], [255, 170, 85]);
        assert_eq!(screen.cells[0].character, 0xdb);
        assert_eq!(screen.cells[0].foreground, 1);
        assert_eq!(screen.cells[0].background, 15);
    }

    #[test]
    fn rejects_invalid_fields() {
        let mut data = fixture();
        data[0] = 2;
        assert!(parse(&data, None).unwrap_err().contains("version 2"));

        data[0] = 1;
        data[1] = 64;
        assert!(parse(&data, None).unwrap_err().contains("palette"));

        assert!(parse(&fixture(), Some(79)).unwrap_err().contains("width"));
        assert!(
            parse(&fixture()[..HEADER_LENGTH], None)
                .unwrap_err()
                .contains("no screen data")
        );
        assert!(
            parse(&fixture()[..HEADER_LENGTH + 2], None)
                .unwrap_err()
                .contains("complete 80-column rows")
        );
    }
}