bbcat 0.1.12

View ANSI, DIZ, ADF, RIPscrip, and XBin art in terminals or export PNG images
Documentation
use std::{
    env, fs,
    io::{self, IsTerminal, Read},
    path::{Path, PathBuf},
    process::ExitCode,
    time::Duration,
};

mod terminal;

const DEFAULT_DELAY_MS: u64 = 25;
const MAX_DELAY_MS: u64 = 10_000;

struct Options {
    width: Option<usize>,
    chunk_lines: usize,
    output: Option<PathBuf>,
    kitty: bool,
    fit: bool,
    delay: Option<Duration>,
    scale: usize,
    files: Vec<String>,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("bbcat: {message}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<(), String> {
    let Some(options) = parse_args()? else {
        return Ok(());
    };
    if options.output.is_some() && options.files.len() != 1 {
        return Err("--output requires exactly one input file".to_owned());
    }
    if options.output.is_some() && options.delay.is_some() {
        return Err("--slow/--delay cannot be used with --output".to_owned());
    }
    if options.output.is_some() && options.kitty {
        return Err("--output and --kitty cannot be used together".to_owned());
    }
    if options.fit && !options.kitty {
        return Err("--fit requires --kitty".to_owned());
    }
    if options.scale > 1 && options.output.is_none() && !options.kitty {
        return Err("--2x requires --kitty or --output FILE".to_owned());
    }
    let stdout_is_terminal = io::stdout().is_terminal();
    if options.kitty && !stdout_is_terminal {
        return Err("--kitty requires terminal stdout".to_owned());
    }
    if options.kitty && !terminal::supports_kitty()? {
        return Err(
            "terminal does not support the Kitty graphics protocol; omit --kitty for UTF-8 output"
                .to_owned(),
        );
    }

    let terminal_columns = stdout_is_terminal.then(terminal::width).flatten();
    if options.kitty && terminal_columns.is_none() {
        return Err("cannot determine terminal width for Kitty output".to_owned());
    }
    let mut stdout = io::stdout().lock();
    for file in &options.files {
        let data = read(file)?;
        let document = bbcat::render_named(&data, options.width, file)
            .map_err(|error| format!("{file}: {error}"))?;
        if let Some(path) = &options.output {
            let png = bbcat::encode_screen_scaled(
                &document.screen,
                0,
                document.screen.height,
                options.scale,
            )
            .map_err(|error| format!("{file}: {error}"))?;
            write_png(&mut stdout, path, &png)?;
        } else if options.kitty {
            if let Some(delay) = options.delay {
                if let Some(columns) = terminal_columns {
                    if options.fit {
                        bbcat::write_screen_slow_scaled_fit(
                            &mut stdout,
                            &document.screen,
                            delay,
                            options.scale,
                            columns,
                        )
                        .map_err(|error| format!("{file}: {error}"))?;
                    } else {
                        bbcat::write_screen_slow_scaled_cropped(
                            &mut stdout,
                            &document.screen,
                            delay,
                            options.scale,
                            columns,
                        )
                        .map_err(|error| format!("{file}: {error}"))?;
                    }
                } else {
                    bbcat::write_screen_slow_scaled(
                        &mut stdout,
                        &document.screen,
                        delay,
                        options.scale,
                    )
                    .map_err(|error| format!("{file}: {error}"))?;
                }
            } else if let Some(columns) = terminal_columns {
                if options.fit {
                    bbcat::write_screen_scaled_fit(
                        &mut stdout,
                        &document.screen,
                        options.chunk_lines,
                        options.scale,
                        columns,
                    )
                    .map_err(|error| format!("{file}: {error}"))?;
                } else {
                    bbcat::write_screen_scaled_cropped(
                        &mut stdout,
                        &document.screen,
                        options.chunk_lines,
                        options.scale,
                        columns,
                    )
                    .map_err(|error| format!("{file}: {error}"))?;
                }
            } else {
                bbcat::write_screen_scaled(
                    &mut stdout,
                    &document.screen,
                    options.chunk_lines,
                    options.scale,
                )
                .map_err(|error| format!("{file}: {error}"))?;
            }
        } else if let Some(delay) = options.delay {
            if let Some(columns) = terminal_columns {
                bbcat::write_text_slow_cropped(&mut stdout, &document.screen, delay, columns)
                    .map_err(|error| format!("{file}: {error}"))?;
            } else {
                bbcat::write_text_slow(&mut stdout, &document.screen, delay)
                    .map_err(|error| format!("{file}: {error}"))?;
            }
        } else if let Some(columns) = terminal_columns {
            bbcat::write_text_cropped(&mut stdout, &document.screen, columns)
                .map_err(|error| format!("{file}: {error}"))?;
        } else {
            bbcat::write_text(&mut stdout, &document.screen)
                .map_err(|error| format!("{file}: {error}"))?;
        }
    }
    Ok(())
}

fn read(path: &str) -> Result<Vec<u8>, String> {
    if path == "-" {
        let mut data = Vec::new();
        io::stdin()
            .read_to_end(&mut data)
            .map_err(|error| format!("stdin: {error}"))?;
        Ok(data)
    } else {
        fs::read(path).map_err(|error| format!("{path}: {error}"))
    }
}

fn write_png<W: io::Write>(output: &mut W, path: &Path, png: &[u8]) -> Result<(), String> {
    if path == Path::new("-") {
        output
            .write_all(png)
            .and_then(|()| output.flush())
            .map_err(|error| format!("stdout: {error}"))
    } else {
        fs::write(path, png).map_err(|error| format!("{}: {error}", path.display()))
    }
}

fn parse_args() -> Result<Option<Options>, String> {
    let mut width = None;
    let mut chunk_lines = env::var("LINES")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .map_or(24, |lines| lines.saturating_sub(1).clamp(1, 64));
    let mut output = None;
    let mut kitty = false;
    let mut fit = false;
    let mut delay = None;
    let mut scale = 1;
    let mut files = Vec::new();
    let mut arguments = env::args().skip(1);
    while let Some(argument) = arguments.next() {
        match argument.as_str() {
            "-h" | "--help" => {
                print_help();
                return Ok(None);
            }
            "-V" | "--version" => {
                println!("bbcat {}", env!("CARGO_PKG_VERSION"));
                return Ok(None);
            }
            "-w" | "--width" => width = Some(number(&argument, arguments.next())?),
            "--chunk-lines" => chunk_lines = number(&argument, arguments.next())?,
            "-o" | "--output" => {
                output = Some(PathBuf::from(
                    arguments
                        .next()
                        .ok_or_else(|| format!("{argument} requires a path"))?,
                ));
            }
            "--kitty" => kitty = true,
            "--fit" => fit = true,
            "--slow" => {
                delay.get_or_insert(Duration::from_millis(DEFAULT_DELAY_MS));
            }
            "--delay" => {
                delay = Some(Duration::from_millis(milliseconds(
                    &argument,
                    arguments.next(),
                )?));
            }
            "--2x" => scale = 2,
            "--" => {
                files.extend(arguments);
                break;
            }
            "-" => files.push(argument),
            _ if argument.starts_with('-') => return Err(format!("unknown option: {argument}")),
            _ => files.push(argument),
        }
    }
    if files.is_empty() {
        files.push("-".to_owned());
    }
    if chunk_lines == 0 || chunk_lines > 256 {
        return Err("--chunk-lines must be between 1 and 256".to_owned());
    }
    Ok(Some(Options {
        width,
        chunk_lines,
        output,
        kitty,
        fit,
        delay,
        scale,
        files,
    }))
}

fn number(option: &str, value: Option<String>) -> Result<usize, String> {
    value
        .ok_or_else(|| format!("{option} requires a number"))?
        .parse()
        .map_err(|_| format!("{option} requires a positive integer"))
}

fn milliseconds(option: &str, value: Option<String>) -> Result<u64, String> {
    let value = value
        .ok_or_else(|| format!("{option} requires milliseconds between 1 and {MAX_DELAY_MS}"))?
        .parse::<u64>()
        .map_err(|_| format!("{option} requires milliseconds between 1 and {MAX_DELAY_MS}"))?;
    if !(1..=MAX_DELAY_MS).contains(&value) {
        return Err(format!(
            "{option} requires milliseconds between 1 and {MAX_DELAY_MS}"
        ));
    }
    Ok(value)
}

fn print_help() {
    println!(
        r#"bbcat {}
Render character art as UTF-8, or supported BBS art as Kitty graphics or PNG.

Usage: bbcat [OPTIONS] [FILE]...

Arguments:
  [FILE]...                 .ANS/.DIZ/.ADF/.RIP/.XB files; use - or omit for stdin

Options:
  -w, --width COLS          Override text width; must match fixed binary/vector widths
      --chunk-lines ROWS    Kitty image height (default: LINES - 1, or 24)
      --kitty               Use Kitty graphics instead of UTF-8 text
      --fit                 Scale complete Kitty art to terminal width instead of cropping
      --slow                Reveal character art one row at a time (25 ms/row)
      --delay MS            Set the slow-mode row delay (1..=10000)
      --2x                  Double Kitty or PNG output dimensions
  -o, --output FILE         Write a PNG file; use - for stdout
  -h, --help                Print help
  -V, --version             Print version"#,
        env!("CARGO_PKG_VERSION")
    );
}

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

    #[test]
    fn validates_slow_mode_delays() {
        assert_eq!(milliseconds("--delay", Some("25".to_owned())), Ok(25));
        for value in [
            None,
            Some("0".to_owned()),
            Some("10001".to_owned()),
            Some("fast".to_owned()),
        ] {
            assert!(milliseconds("--delay", value).is_err());
        }
    }

    #[test]
    fn dash_output_writes_png_to_stdout() {
        let mut output = Vec::new();
        write_png(&mut output, Path::new("-"), b"PNG").unwrap();
        assert_eq!(output, b"PNG");
    }
}