Files
ansi/ansi.c
T
funkpower 74a684c269 ansi: show ANSI art in a UTF-8 terminal, or as plain or shaded text
Draws an .ans file into a grid the way an ANSI-BBS terminal would (cursor
moves, the wrap at the SAUCE width, iCE colours) and writes it out as UTF-8:
in colour, --plain, or --mono with coloured areas shaded by brightness. info
prints the SAUCE record. One C99 file with no dependencies.
2026-10-22 19:30:17 -04:00

474 lines
16 KiB
C
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* ansi - show ANSI art in a modern terminal, or turn it into plain text.
*
* An .ans file is CP437 text with ANSI escape codes, usually laid out for an
* 80-column terminal that wraps it. ansi draws it into a grid the way a BBS
* terminal would (cursor moves, wrapping, iCE colours, the SAUCE record), then
* writes the grid out as UTF-8: in colour, as plain characters, or in
* monochrome with coloured areas redrawn as shading.
*/
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define VERSION "1.0.0"
#define MAX_WIDTH 1000
#define MAX_CELLS (1u << 24) /* about 80 MB of grid; art past it is dropped */
/* CP437 as UTF-8. 0x01-0x1F are the glyphs art uses them for, not controls. */
static const char *const cp437[256] = {
" ", "☺", "☻", "♥", "♦", "♣", "♠", "•",
"◘", "○", "◙", "♂", "♀", "♪", "♫", "☼",
"►", "◄", "↕", "‼", "¶", "§", "▬", "↨",
"↑", "↓", "→", "←", "∟", "↔", "▲", "▼",
" ", "!", "\"", "#", "$", "%", "&", "'",
"(", ")", "*", "+", ",", "-", ".", "/",
"0", "1", "2", "3", "4", "5", "6", "7",
"8", "9", ":", ";", "<", "=", ">", "\?",
"@", "A", "B", "C", "D", "E", "F", "G",
"H", "I", "J", "K", "L", "M", "N", "O",
"P", "Q", "R", "S", "T", "U", "V", "W",
"X", "Y", "Z", "[", "\\", "]", "^", "_",
"`", "a", "b", "c", "d", "e", "f", "g",
"h", "i", "j", "k", "l", "m", "n", "o",
"p", "q", "r", "s", "t", "u", "v", "w",
"x", "y", "z", "{", "|", "}", "~", "⌂",
"Ç", "ü", "é", "â", "ä", "à", "å", "ç",
"ê", "ë", "è", "ï", "î", "ì", "Ä", "Å",
"É", "æ", "Æ", "ô", "ö", "ò", "û", "ù",
"ÿ", "Ö", "Ü", "¢", "£", "¥", "₧", "ƒ",
"á", "í", "ó", "ú", "ñ", "Ñ", "ª", "º",
"¿", "⌐", "¬", "½", "¼", "¡", "«", "»",
"░", "▒", "▓", "│", "┤", "╡", "╢", "╖",
"╕", "╣", "║", "╗", "╝", "╜", "╛", "┐",
"└", "┴", "┬", "├", "─", "┼", "╞", "╟",
"╚", "╔", "╩", "╦", "╠", "═", "╬", "╧",
"╨", "╤", "╥", "╙", "╘", "╒", "╓", "╫",
"╪", "┘", "┌", "█", "▄", "▌", "▐", "▀",
"α", "ß", "Γ", "π", "Σ", "σ", "µ", "τ",
"Φ", "Θ", "Ω", "δ", "∞", "φ", "ε", "∩",
"≡", "±", "≥", "≤", "⌠", "⌡", "÷", "≈",
"°", "∙", "·", "√", "ⁿ", "²", "■", " ",
};
/* How bright each of the 16 CGA colours looks (CIE L*, 0-100). */
static const double lightness[16] = {
0.0000, 35.0952, 60.5599, 45.6931, 19.6482, 40.3255, 63.0540, 69.6102,
36.1459, 60.2599, 88.9731, 97.4042, 46.5096, 65.7308, 91.9852, 100.0000
};
enum mode { COLOUR, PLAIN, MONO };
typedef struct {
unsigned char ch; /* CP437 byte; 0 is stored as a space */
unsigned char fg, bg; /* 0-7 */
unsigned char bold, blink; /* blink is iCE: the bright background */
} cell;
typedef struct {
char title[36], author[21], group[21], date[9];
unsigned width, height, comments, flags;
unsigned char datatype, filetype;
int present;
} sauce;
typedef struct {
cell *cells;
size_t rows, cap;
unsigned width;
} grid;
static const char *progname = "ansi";
static void *xrealloc(void *p, size_t n)
{
p = realloc(p, n);
if (!p) {
fprintf(stderr, "%s: out of memory\n", progname);
exit(2);
}
return p;
}
static unsigned char *slurp(const char *path, size_t *len)
{
FILE *f = strcmp(path, "-") ? fopen(path, "rb") : stdin;
if (!f) {
fprintf(stderr, "%s: %s: %s\n", progname, path, strerror(errno));
return NULL;
}
size_t cap = 65536, n = 0;
unsigned char *buf = xrealloc(NULL, cap);
for (;;) {
size_t got = fread(buf + n, 1, cap - n, f);
n += got;
if (n < cap)
break;
buf = xrealloc(buf, cap *= 2);
}
if (ferror(f)) {
fprintf(stderr, "%s: %s: read error\n", progname, path);
free(buf);
buf = NULL;
}
if (f != stdin)
fclose(f);
*len = n;
return buf;
}
static void field(char *dst, const unsigned char *src, size_t n)
{
memcpy(dst, src, n);
dst[n] = '\0';
for (size_t i = n; i > 0 && (dst[i - 1] == ' ' || dst[i - 1] == '\0'); i--)
dst[i - 1] = '\0';
}
static unsigned u16(const unsigned char *p) { return p[0] | p[1] << 8; }
/* Read the SAUCE record and shorten len to the art itself. */
static void read_sauce(const unsigned char *data, size_t *len, sauce *s)
{
memset(s, 0, sizeof *s);
s->width = 80;
if (*len >= 128 && !memcmp(data + *len - 128, "SAUCE00", 7)) {
const unsigned char *r = data + *len - 128;
s->present = 1;
field(s->title, r + 7, 35);
field(s->author, r + 42, 20);
field(s->group, r + 62, 20);
field(s->date, r + 82, 8);
s->datatype = r[94];
s->filetype = r[95];
s->comments = r[104];
s->flags = r[105];
s->height = u16(r + 98);
if (s->datatype == 1 && s->filetype <= 1 && u16(r + 96) && u16(r + 96) <= MAX_WIDTH)
s->width = u16(r + 96);
size_t trailer = 128 + (s->comments ? 5 + 64 * s->comments : 0);
*len = *len > trailer ? *len - trailer : 0;
}
const unsigned char *eof = memchr(data, 0x1A, *len);
if (eof)
*len = eof - data;
}
/* The cell at x, y, growing the grid to reach it; NULL past MAX_CELLS. */
static cell *at(grid *g, unsigned x, size_t y)
{
if (y >= MAX_CELLS / g->width)
return NULL;
if (y >= g->rows) {
size_t want = y + 1;
if (want > g->cap) {
g->cap = want * 2;
g->cells = xrealloc(g->cells, g->cap * g->width * sizeof(cell));
}
for (size_t r = g->rows; r < want; r++)
for (unsigned c = 0; c < g->width; c++)
g->cells[r * g->width + c] = (cell){' ', 7, 0, 0, 0};
g->rows = want;
}
return &g->cells[y * g->width + x];
}
/* Lay the art out in a grid, the way an ANSI-BBS terminal of the given width would. */
static void render(grid *g, const unsigned char *d, size_t n)
{
unsigned x = 0, sx = 0, w = g->width;
size_t y = 0, sy = 0;
unsigned char fg = 7, bg = 0, bold = 0, blink = 0;
for (size_t i = 0; i < n;) {
unsigned char b = d[i];
if (b == 0x1B && i + 1 < n && d[i + 1] == '[') {
size_t j = i + 2, ps = j;
while (j < n && d[j] >= 0x30 && d[j] <= 0x3F)
j++;
size_t pe = j;
while (j < n && d[j] >= 0x20 && d[j] <= 0x2F)
j++;
if (j >= n || d[j] < 0x40 || d[j] > 0x7E)
break; /* a broken sequence ends the art */
unsigned char cmd = d[j];
i = j + 1;
if (pe > ps && d[ps] == '?')
continue; /* private modes, such as line wrap */
unsigned p[16], np = 0;
if (pe > ps) {
p[0] = 0;
np = 1;
for (size_t k = ps; k < pe; k++) {
if (d[k] == ';') {
if (np < 16)
p[np++] = 0;
} else if (d[k] >= '0' && d[k] <= '9' && np <= 16) {
p[np - 1] = p[np - 1] * 10 + (d[k] - '0');
}
}
}
unsigned arg = np && p[0] ? p[0] : 1;
switch (cmd) {
case 'm':
if (!np)
p[np++] = 0;
for (unsigned k = 0; k < np; k++) {
unsigned v = p[k];
if (v == 0)
fg = 7, bg = 0, bold = 0, blink = 0;
else if (v == 1)
bold = 1;
else if (v == 5)
blink = 1;
else if (v == 22)
bold = 0;
else if (v == 25)
blink = 0;
else if (v >= 30 && v <= 37)
fg = v - 30;
else if (v == 39)
fg = 7;
else if (v >= 40 && v <= 47)
bg = v - 40;
else if (v == 49)
bg = 0;
}
break;
case 'C': x = x + arg < w ? x + arg : w; break;
case 'D': x = x > arg ? x - arg : 0; break;
case 'A': y = y > arg ? y - arg : 0; break;
case 'B': y += arg; break;
case 'H':
case 'f':
y = (np && p[0] ? p[0] : 1) - 1;
x = (np > 1 && p[1] ? p[1] : 1) - 1;
if (x > w - 1)
x = w - 1;
break;
case 's': sx = x, sy = y; break;
case 'u': x = sx, y = sy; break;
case 'J':
if (arg == 2)
g->rows = 0, x = 0, y = 0;
break;
}
continue;
}
i++;
if (b == '\r') {
x = 0;
} else if (b == '\n') {
x = 0, y++;
} else if (b == 0x1B) {
/* a lone escape */
} else {
unsigned count = b == '\t' ? 8 - x % 8 : 1;
unsigned char ch = b == '\t' || b == 0 ? ' ' : b;
while (count--) {
if (x >= w)
x = 0, y++; /* wrap only when the next character arrives */
cell *c = at(g, x, y);
if (c)
*c = (cell){ch, fg, bg, bold, blink};
x++;
}
}
}
}
static int same(const cell *a, const cell *b)
{
return a->fg == b->fg && a->bg == b->bg && a->bold == b->bold && a->blink == b->blink;
}
static int level(double l)
{
return l < 15 ? 0 : l < 40 ? 1 : l < 60 ? 2 : l < 85 ? 3 : 4;
}
/* How much of a cell the foreground covers, top half and bottom half; -1 if not a block. */
static double cover(unsigned char ch, int half)
{
switch (ch) {
case ' ': return 0;
case 0xDB: return 1; /* full block */
case 0xDF: return half ? 0 : 1; /* upper half */
case 0xDC: return half ? 1 : 0; /* lower half */
case 0xDD: case 0xDE: return .5; /* left and right halves */
case 0xB0: return .25;
case 0xB1: return .5;
case 0xB2: return .75;
default: return -1;
}
}
/* One character standing in for a coloured cell: shading by brightness, half blocks for halves. */
static const char *mono(const cell *c)
{
static const char *const shade[5] = {" ", "░", "▒", "▓", "█"};
if (cover(c->ch, 0) < 0)
return cp437[c->ch];
double f = lightness[c->fg + 8 * c->bold], b = lightness[c->bg + 8 * c->blink];
int top = level(cover(c->ch, 0) * f + (1 - cover(c->ch, 0)) * b);
int bot = level(cover(c->ch, 1) * f + (1 - cover(c->ch, 1)) * b);
if (top - bot >= 2)
return "▀";
if (bot - top >= 2)
return "▄";
return shade[top > bot ? top : bot];
}
static void emit(const grid *g, enum mode m, FILE *out)
{
/* Lines are built first so that trailing blank ones can be dropped. */
char **lines = xrealloc(NULL, (g->rows + 1) * sizeof *lines);
size_t nlines = 0;
for (size_t y = 0; y < g->rows; y++) {
const cell *row = &g->cells[y * g->width];
unsigned len = g->width;
while (len && row[len - 1].ch == ' ' && !row[len - 1].bg && !row[len - 1].blink)
len--;
size_t cap = 64 + len * 24, n = 0;
char *s = xrealloc(NULL, cap);
const cell *last = NULL;
for (unsigned x = 0; x < len; x++) {
const cell *c = &row[x];
const char *glyph = m == MONO ? mono(c) : cp437[c->ch];
if (m == COLOUR) {
int keep = last && (same(c, last) ||
(c->ch == ' ' && c->bg == last->bg && c->blink == last->blink));
if (!keep) {
n += sprintf(s + n, "\033[0;%s%d;%dm", c->bold ? "1;" : "",
30 + c->fg, (c->blink ? 100 : 40) + c->bg);
last = c;
}
}
size_t gl = strlen(glyph);
memcpy(s + n, glyph, gl);
n += gl;
}
if (m == COLOUR && len)
n += sprintf(s + n, "\033[0m");
else
while (n && s[n - 1] == ' ')
n--;
s[n] = '\0';
lines[nlines++] = s;
}
while (nlines && !*lines[nlines - 1])
free(lines[--nlines]);
for (size_t i = 0; i < nlines; i++) {
fputs(lines[i], out);
fputc('\n', out);
free(lines[i]);
}
free(lines);
}
static void info(const char *path, const sauce *s, const grid *g, FILE *out)
{
static const char *const types[] = {"ASCII", "ANSi", "ANSiMation"};
fprintf(out, "%s\n", path);
if (!s->present) {
fprintf(out, " no SAUCE record; laid out at %u columns, %zu row%s\n", g->width, g->rows,
g->rows == 1 ? "" : "s");
return;
}
if (*s->title) fprintf(out, " title %s\n", s->title);
if (*s->author) fprintf(out, " author %s\n", s->author);
if (*s->group) fprintf(out, " group %s\n", s->group);
if (strlen(s->date) == 8)
fprintf(out, " date %.4s-%.2s-%.2s\n", s->date, s->date + 4, s->date + 6);
fprintf(out, " size %ux%u", s->width, s->height ? s->height : (unsigned)g->rows);
if (s->datatype == 1 && s->filetype <= 2)
fprintf(out, ", %s", types[s->filetype]);
if (s->flags & 1)
fprintf(out, ", iCE colours");
fputc('\n', out);
}
static void usage(FILE *out)
{
fprintf(out,
"usage: %s [-p | -m] [-w WIDTH] [FILE...]\n"
" %s info FILE...\n"
"\n"
"Show ANSI art (CP437 with ANSI colour codes, as .ans files are) in a UTF-8\n"
"terminal, laid out the way a BBS terminal would draw it. With no FILE, or\n"
"when FILE is -, it reads standard input.\n"
"\n"
" -p, --plain the characters only, no colour\n"
" -m, --mono no colour, with coloured areas redrawn as shading by\n"
" brightness (for newsgroups and mail that show no colour)\n"
" -w, --width N lay the art out N columns wide (default: SAUCE, else 80)\n"
" info print each file's SAUCE record: title, artist, size\n"
" -h, --help show this help\n"
" -V, --version show the version\n"
"\n"
"Examples:\n"
" %s piece.ans | less -R\n"
" %s --mono piece.ans > piece.txt\n",
progname, progname, progname, progname);
}
int main(int argc, char **argv)
{
enum mode m = COLOUR;
unsigned width = 0;
int show_info = 0, status = 0, files = 0;
for (int i = 1; i < argc; i++) {
const char *a = argv[i];
if (!strcmp(a, "-h") || !strcmp(a, "--help") || !strcmp(a, "help")) {
usage(stdout);
return 0;
} else if (!strcmp(a, "-V") || !strcmp(a, "--version")) {
printf("%s %s\n", progname, VERSION);
return 0;
} else if (!strcmp(a, "-p") || !strcmp(a, "--plain")) {
m = PLAIN;
} else if (!strcmp(a, "-m") || !strcmp(a, "--mono")) {
m = MONO;
} else if (!strcmp(a, "-w") || !strcmp(a, "--width")) {
if (++i >= argc || (width = (unsigned)strtoul(argv[i], NULL, 10)) < 1 || width > 1000) {
fprintf(stderr, "%s: --width needs a number from 1 to 1000\n", progname);
return 1;
}
} else if (!strcmp(a, "info") && files == 0 && !show_info) {
show_info = 1;
} else if (a[0] == '-' && a[1]) {
fprintf(stderr, "%s: unknown option %s\n", progname, a);
usage(stderr);
return 1;
} else {
argv[++files] = argv[i]; /* keep the file names at the front */
}
}
if (!files)
argv[++files] = "-";
for (int f = 1; f <= files; f++) {
size_t len;
unsigned char *data = slurp(argv[f], &len);
if (!data) {
status = 2;
continue;
}
sauce s;
read_sauce(data, &len, &s);
grid g = {NULL, 0, 0, width ? width : s.width};
render(&g, data, len);
if (show_info)
info(argv[f], &s, &g, stdout);
else
emit(&g, m, stdout);
free(g.cells);
free(data);
}
return status;
}