Files
0xa0.dev/inc/art.php
T
2026-10-13 15:05:21 +02:00

232 lines
8.8 KiB
PHP

<?php
/**
* ANSI art (a .ans file you can `cat` in a terminal) for the side panels.
* Understands foreground colors: truecolor (38;2;r;g;b), 256-color (38;5;n),
* the 16 basic ones, and resets; everything else is ignored. The right panel
* is the left mirrored, swapping /\ () <> [] {} so shapes still read.
*
* The art ships as a stylesheet (sideart.php), not HTML: one full-size text
* layer per color, each the `content` of a ::before on an empty <b>, stacked
* by style.css. It's render-blocking and cached, so the first paint already
* has it; inline, it made the page too big for one network round trip and
* Firefox painted the page half-arrived (a dark empty flash) on cached loads.
*/
function ansi_color(array $p, ?string $cur): ?string
{
static $basic = ['000000', 'aa0000', '00aa00', 'aa5500', '0000aa', 'aa00aa', '00aaaa', 'aaaaaa',
'555555', 'ff5555', '55ff55', 'ffff55', '5555ff', 'ff55ff', '55ffff', 'ffffff'];
for ($i = 0; $i < count($p); $i++) {
$n = (int) $p[$i];
if ($n === 0 || $n === 39) {
$cur = null;
} elseif ($n >= 30 && $n <= 37) {
$cur = $basic[$n - 30];
} elseif ($n >= 90 && $n <= 97) {
$cur = $basic[$n - 82];
} elseif ($n === 38 && ($p[$i + 1] ?? '') === '2') {
$cur = sprintf('%02x%02x%02x', $p[$i + 2] ?? 0, $p[$i + 3] ?? 0, $p[$i + 4] ?? 0);
$i += 4;
} elseif ($n === 38 && ($p[$i + 1] ?? '') === '5') {
$c = (int) ($p[$i + 2] ?? 0);
$i += 2;
if ($c < 16) {
$cur = $basic[$c];
} elseif ($c < 232) {
$c -= 16;
$lvl = static fn(int $v): int => $v ? 55 + 40 * $v : 0;
$cur = sprintf('%02x%02x%02x', $lvl(intdiv($c, 36)), $lvl(intdiv($c, 6) % 6), $lvl($c % 6));
} else {
$cur = str_repeat(sprintf('%02x', 8 + 10 * ($c - 232)), 3);
}
}
}
return $cur;
}
/**
* The stylesheet for both panels, cached on disk until the .ans file (or this
* file) changes. Returns [path to the css, layers on the left, on the right].
*/
function ansi_css_cached(string $file): array
{
$dir = (defined('DATA_DIR') ? DATA_DIR : sys_get_temp_dir()) . '/art-cache';
$base = $dir . '/' . md5($file);
$key = $base . '-' . filemtime($file) . '-' . filemtime(__FILE__);
if (!is_file("$key.css") || !is_file("$key.json")) {
@mkdir($dir, 0755, true);
array_map('unlink', glob($base . '-*') ?: []); // every deploy bumps the mtimes
$css = '';
$counts = [];
foreach (['left' => false, 'right' => true] as $side => $mirror) {
$layers = ansi_layers($file, $mirror);
$counts[$side] = count($layers);
foreach ($layers as $i => [$color, $text]) {
$css .= ".side-$side > b:nth-child(" . ($i + 1) . ')::before{content:' . ansi_css_string($text)
. ($color !== '' ? ";color:#$color" : '') . "}\n";
}
}
foreach (["$key.css" => $css, "$key.json" => json_encode($counts)] as $path => $data) {
$tmp = $path . '.' . getmypid();
if (@file_put_contents($tmp, $data) !== false) {
rename($tmp, $path);
}
}
}
return ["$key.css", ...array_values((array) json_decode((string) @file_get_contents("$key.json"), true))];
}
/** A CSS string literal; newlines become \A (the space after it ends the escape). */
function ansi_css_string(string $text): string
{
return '"' . str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\A '], $text) . '"';
}
/** The art as [[hex color or '', text], ...]: one full-size layer per color. */
function ansi_layers(string $file, bool $mirror = false): array
{
$raw = (string) file_get_contents($file);
if (!preg_match('/[\x80-\xff]/', $raw)) {
$layers = ansi_layers_bytes($raw, $mirror);
if ($layers !== null) {
return $layers;
}
}
return ansi_layers_cells($raw, $mirror);
}
/**
* Plain-ASCII art as [lines of chars, lines of color-index bytes, color => index],
* or null with too many colors for a byte. One regex pass over the whole file;
* kept per request so the mirrored side doesn't parse it a second time.
*/
function ansi_parse_bytes(string $raw): ?array
{
static $memo = [], $done = [];
$key = md5($raw);
if (array_key_exists($key, $done)) {
return $done[$key];
}
$index = ['' => 0]; // color hex => byte (0 = uncolored)
$chars = [];
$masks = [];
$c = $m = '';
$cur = null;
foreach (preg_split('/(\e\[[0-9;]*m|\n)/', rtrim($raw, "\n"), -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $part) {
if ($part === "\n") { // colors don't carry across lines
$chars[] = $c;
$masks[] = $m;
$c = $m = '';
$cur = null;
} elseif ($part[0] === "\e") {
// a handful of distinct codes repeated thousands of times: decode each once
$cur = $memo[$part . '|' . $cur] ??= ansi_color(explode(';', substr($part, 2, -1)), $cur);
} else {
$k = $index[$cur ?? ''] ??= count($index);
$c .= $part;
$m .= str_repeat(chr($k), strlen($part));
}
}
$chars[] = $c;
$masks[] = $m;
return $done[$key] = count($index) > 250 ? null : [$chars, $masks, $index];
}
/**
* Fast path for plain-ASCII art (one byte per cell): each line becomes a
* string of characters plus a parallel string of color indices, and every
* color layer is cut out of the whole art with bitwise string ops, which run
* in C. Returns null if there are too many colors to index in a byte.
*/
function ansi_layers_bytes(string $raw, bool $mirror): ?array
{
static $from = null, $to = null; // byte 0 -> 0xff, anything else -> 0
$from ??= implode(array_map('chr', range(0, 255)));
$to ??= "\xff" . str_repeat("\0", 255);
$parsed = ansi_parse_bytes($raw);
if ($parsed === null) {
return null;
}
[$chars, $masks, $index] = $parsed;
$width = max(array_map('strlen', $chars) ?: [0]);
foreach ($chars as $i => $c) {
$chars[$i] = str_pad($c, $width);
$masks[$i] = str_pad($masks[$i], $width, "\0");
if ($mirror) {
$chars[$i] = strtr(strrev($chars[$i]), '/\\()<>[]{}', '\\/)(><][}{');
$masks[$i] = strrev($masks[$i]);
}
}
// the whole art as one string; 0xfe in the mask marks the line breaks
$all = implode("\n", $chars);
$mask = implode("\xfe", $masks);
$blank = implode("\n", array_fill(0, count($chars), str_repeat(' ', $width)));
$n = strlen($all);
$layers = [];
foreach ($index as $color => $k) {
$sel = strtr($mask ^ str_repeat(chr($k), $n), $from, $to); // 0xff where this color, else 0
$layer = ($all & $sel) | ($blank & ~$sel);
if (trim($layer) !== '') { // skip colors that only touch blanks
$layers[] = [(string) $color, $layer];
}
}
return $layers;
}
/** General path: a [char, color] cell at a time, so multibyte characters work. */
function ansi_layers_cells(string $raw, bool $mirror): array
{
static $flip = ['/' => '\\', '\\' => '/', '(' => ')', ')' => '(', '<' => '>', '>' => '<',
'[' => ']', ']' => '[', '{' => '}', '}' => '{'];
$lines = [];
foreach (explode("\n", rtrim($raw, "\n")) as $line) {
// split into [char, color] cells
$cells = [];
$cur = null;
foreach (preg_split('/(\e\[[0-9;]*m)/', $line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $part) {
if ($part[0] === "\e") {
$cur = ansi_color(explode(';', substr($part, 2, -1)), $cur);
continue;
}
foreach (mb_str_split($part) as $ch) {
$cells[] = [$ch, $cur];
}
}
$lines[] = $cells;
}
$width = max(array_map('count', $lines) ?: [0]);
foreach ($lines as &$cells) {
$cells = array_pad($cells, $width, [' ', null]);
if ($mirror) {
$cells = array_map(static fn($c) => [$flip[$c[0]] ?? $c[0], $c[1]], array_reverse($cells));
}
}
unset($cells);
$colors = [];
foreach ($lines as $cells) {
foreach ($cells as [$ch, $color]) {
if ($ch !== ' ') {
$colors[$color ?? ''] = true;
}
}
}
$layers = [];
foreach (array_keys($colors) as $color) {
$rows = [];
foreach ($lines as $cells) {
$row = '';
foreach ($cells as [$ch, $c]) {
$row .= ($ch !== ' ' && ($c ?? '') === $color) ? $ch : ' ';
}
$rows[] = $row;
}
$layers[] = [(string) $color, implode("\n", $rows)];
}
return $layers;
}