[] {} 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 , 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; }