bbcat 0.1.12

View ANSI, DIZ, ADF, RIPscrip, and XBin art in terminals or export PNG images
Documentation
use std::{
    io::{self, Write},
    thread,
    time::Duration,
};

use crate::{Screen, png::VGA_PALETTE};

const CP437: [char; 256] = [
    '\u{20}', '\u{263a}', '\u{263b}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
    '\u{25d8}', '\u{25cb}', '\u{25d9}', '\u{2642}', '\u{2640}', '\u{266a}', '\u{266b}', '\u{263c}',
    '\u{25ba}', '\u{25c4}', '\u{2195}', '\u{203c}', '\u{b6}', '\u{a7}', '\u{25ac}', '\u{21a8}',
    '\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221f}', '\u{2194}', '\u{25b2}', '\u{25bc}',
    '\u{20}', '\u{21}', '\u{22}', '\u{23}', '\u{24}', '\u{25}', '\u{26}', '\u{27}', '\u{28}',
    '\u{29}', '\u{2a}', '\u{2b}', '\u{2c}', '\u{2d}', '\u{2e}', '\u{2f}', '\u{30}', '\u{31}',
    '\u{32}', '\u{33}', '\u{34}', '\u{35}', '\u{36}', '\u{37}', '\u{38}', '\u{39}', '\u{3a}',
    '\u{3b}', '\u{3c}', '\u{3d}', '\u{3e}', '\u{3f}', '\u{40}', '\u{41}', '\u{42}', '\u{43}',
    '\u{44}', '\u{45}', '\u{46}', '\u{47}', '\u{48}', '\u{49}', '\u{4a}', '\u{4b}', '\u{4c}',
    '\u{4d}', '\u{4e}', '\u{4f}', '\u{50}', '\u{51}', '\u{52}', '\u{53}', '\u{54}', '\u{55}',
    '\u{56}', '\u{57}', '\u{58}', '\u{59}', '\u{5a}', '\u{5b}', '\u{5c}', '\u{5d}', '\u{5e}',
    '\u{5f}', '\u{60}', '\u{61}', '\u{62}', '\u{63}', '\u{64}', '\u{65}', '\u{66}', '\u{67}',
    '\u{68}', '\u{69}', '\u{6a}', '\u{6b}', '\u{6c}', '\u{6d}', '\u{6e}', '\u{6f}', '\u{70}',
    '\u{71}', '\u{72}', '\u{73}', '\u{74}', '\u{75}', '\u{76}', '\u{77}', '\u{78}', '\u{79}',
    '\u{7a}', '\u{7b}', '\u{7c}', '\u{7d}', '\u{7e}', '\u{2302}', '\u{c7}', '\u{fc}', '\u{e9}',
    '\u{e2}', '\u{e4}', '\u{e0}', '\u{e5}', '\u{e7}', '\u{ea}', '\u{eb}', '\u{e8}', '\u{ef}',
    '\u{ee}', '\u{ec}', '\u{c4}', '\u{c5}', '\u{c9}', '\u{e6}', '\u{c6}', '\u{f4}', '\u{f6}',
    '\u{f2}', '\u{fb}', '\u{f9}', '\u{ff}', '\u{d6}', '\u{dc}', '\u{a2}', '\u{a3}', '\u{a5}',
    '\u{20a7}', '\u{192}', '\u{e1}', '\u{ed}', '\u{f3}', '\u{fa}', '\u{f1}', '\u{d1}', '\u{aa}',
    '\u{ba}', '\u{bf}', '\u{2310}', '\u{ac}', '\u{bd}', '\u{bc}', '\u{a1}', '\u{ab}', '\u{bb}',
    '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}',
    '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255d}', '\u{255c}', '\u{255b}', '\u{2510}',
    '\u{2514}', '\u{2534}', '\u{252c}', '\u{251c}', '\u{2500}', '\u{253c}', '\u{255e}', '\u{255f}',
    '\u{255a}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256c}', '\u{2567}',
    '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}', '\u{256b}',
    '\u{256a}', '\u{2518}', '\u{250c}', '\u{2588}', '\u{2584}', '\u{258c}', '\u{2590}', '\u{2580}',
    '\u{3b1}', '\u{df}', '\u{393}', '\u{3c0}', '\u{3a3}', '\u{3c3}', '\u{b5}', '\u{3c4}',
    '\u{3a6}', '\u{398}', '\u{3a9}', '\u{3b4}', '\u{221e}', '\u{3c6}', '\u{3b5}', '\u{2229}',
    '\u{2261}', '\u{b1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{f7}', '\u{2248}',
    '\u{b0}', '\u{2219}', '\u{b7}', '\u{221a}', '\u{207f}', '\u{b2}', '\u{25a0}', '\u{a0}',
];

pub fn write_screen<W: Write>(output: &mut W, screen: &Screen) -> io::Result<()> {
    write_screen_inner(output, screen, None, screen.width)
}

pub fn write_screen_cropped<W: Write>(
    output: &mut W,
    screen: &Screen,
    columns: usize,
) -> io::Result<()> {
    write_screen_inner(output, screen, None, columns)
}

pub fn write_screen_slow<W: Write>(
    output: &mut W,
    screen: &Screen,
    delay: Duration,
) -> io::Result<()> {
    if screen.raster.is_some() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "slow mode is not supported for RIPscrip graphics",
        ));
    }
    write_screen_inner(output, screen, Some(delay), screen.width)
}

pub fn write_screen_slow_cropped<W: Write>(
    output: &mut W,
    screen: &Screen,
    delay: Duration,
    columns: usize,
) -> io::Result<()> {
    if screen.raster.is_some() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "slow mode is not supported for RIPscrip graphics",
        ));
    }
    write_screen_inner(output, screen, Some(delay), columns)
}

fn write_screen_inner<W: Write>(
    output: &mut W,
    screen: &Screen,
    delay: Option<Duration>,
    columns: usize,
) -> io::Result<()> {
    if columns == 0 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "UTF-8 crop width must be non-zero",
        ));
    }
    if screen.raster.is_some() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "UTF-8 output is not useful for RIPscrip graphics; use --kitty or --output FILE",
        ));
    }
    if screen.cells.iter().any(|cell| cell.character > 255) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "UTF-8 output cannot represent XBin 512-character font glyphs",
        ));
    }
    if !screen.utf8_supported {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "UTF-8 output cannot represent a custom embedded bitmap font; use --kitty or --output FILE",
        ));
    }
    let palette = screen.palette.unwrap_or(VGA_PALETTE);

    for (row_index, row) in screen.cells.chunks_exact(screen.width).enumerate() {
        let mut active_colors = None;
        for cell in row.iter().take(columns) {
            let colors = (cell.foreground & 0x0f, cell.background & 0x0f);
            if active_colors != Some(colors) {
                let foreground = palette[usize::from(colors.0)];
                let background = palette[usize::from(colors.1)];
                write!(
                    output,
                    "\x1b[38;2;{};{};{};48;2;{};{};{}m",
                    foreground[0],
                    foreground[1],
                    foreground[2],
                    background[0],
                    background[1],
                    background[2]
                )?;
                active_colors = Some(colors);
            }
            let mut encoded = [0_u8; 4];
            let character = CP437[usize::from(cell.character)].encode_utf8(&mut encoded);
            output.write_all(character.as_bytes())?;
        }
        output.write_all(b"\x1b[0m\r\n")?;
        if row_index + 1 < screen.height
            && let Some(delay) = delay
        {
            output.flush()?;
            thread::sleep(delay);
        }
    }
    output.flush()
}

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

    fn screen(cells: Vec<Cell>) -> Screen {
        Screen {
            width: cells.len(),
            height: 1,
            cells,
            glyph_height: 16,
            font: None,
            palette: None,
            utf8_supported: true,
            raster: None,
        }
    }

    #[test]
    fn maps_cp437_to_utf8_with_true_color() {
        let mut output = Vec::new();
        write_screen(
            &mut output,
            &screen(vec![
                Cell {
                    character: 0x03,
                    foreground: 1,
                    background: 2,
                },
                Cell {
                    character: 0xdb,
                    foreground: 1,
                    background: 2,
                },
            ]),
        )
        .unwrap();
        assert_eq!(
            String::from_utf8(output).unwrap(),
            "\x1b[38;2;170;0;0;48;2;0;170;0m♥█\x1b[0m\r\n"
        );
    }

    #[test]
    fn rejects_a_512_character_xbin_glyph() {
        let mut output = Vec::new();
        let error = write_screen(
            &mut output,
            &screen(vec![Cell {
                character: 256,
                foreground: 7,
                background: 0,
            }]),
        )
        .unwrap_err();
        assert!(error.to_string().contains("512-character"));
        assert!(output.is_empty());
    }

    #[test]
    fn crops_each_utf8_row_to_the_requested_width() {
        let mut output = Vec::new();
        let screen = screen(
            b"ABCDEF"
                .iter()
                .map(|&character| Cell {
                    character: u16::from(character),
                    foreground: 7,
                    background: 0,
                })
                .collect(),
        );
        write_screen_cropped(&mut output, &screen, 3).unwrap();
        assert!(String::from_utf8(output).unwrap().contains("ABC\x1b[0m"));
    }

    #[test]
    fn rejects_a_zero_utf8_crop_width() {
        let error =
            write_screen_cropped(&mut Vec::new(), &screen(vec![Cell::default()]), 0).unwrap_err();
        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn rejects_a_custom_embedded_bitmap_font() {
        let mut screen = screen(vec![Cell::default()]);
        screen.font = Some(vec![0; 256 * 16]);
        screen.utf8_supported = false;
        let mut output = Vec::new();
        let error = write_screen(&mut output, &screen).unwrap_err();
        assert!(error.to_string().contains("--kitty or --output"));
        assert!(output.is_empty());
    }

    #[test]
    fn rejects_raster_art_with_a_graphical_output_hint() {
        let mut screen = screen(Vec::new());
        screen.width = 80;
        screen.height = 22;
        screen.raster = Some(crate::ansi::Raster {
            width: 640,
            height: 350,
            pixels: vec![0; 640 * 350],
        });
        let error = write_screen(&mut Vec::new(), &screen).unwrap_err();
        assert!(error.to_string().contains("RIPscrip"));
        assert!(error.to_string().contains("--kitty or --output"));
    }

    #[test]
    fn slow_mode_rejects_raster_art() {
        let mut screen = screen(Vec::new());
        screen.width = 80;
        screen.height = 22;
        screen.raster = Some(crate::ansi::Raster {
            width: 640,
            height: 350,
            pixels: vec![0; 640 * 350],
        });
        let error = write_screen_slow(&mut Vec::new(), &screen, Duration::ZERO).unwrap_err();
        assert!(error.to_string().contains("slow mode"));
        assert!(error.to_string().contains("RIPscrip"));
    }
}