diff --git a/inc/art.php b/inc/art.php index 6806374..08e5f99 100644 --- a/inc/art.php +++ b/inc/art.php @@ -58,11 +58,89 @@ function ansi_html_cached(string $file, bool $mirror = false): string } function ansi_html(string $file, bool $mirror = false): string +{ + $raw = (string) file_get_contents($file); + if (!preg_match('/[\x80-\xff]/', $raw)) { + $html = ansi_html_bytes($raw, $mirror); + if ($html !== null) { + return $html; + } + } + return ansi_html_cells($raw, $mirror); +} + +/** + * 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_html_bytes(string $raw, bool $mirror): ?string +{ + 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); + + static $memo = []; + $index = ['' => 0]; // color hex => byte (0 = uncolored) + $chars = []; + $masks = []; + foreach (explode("\n", rtrim($raw, "\n")) as $line) { + $c = ''; + $m = ''; + $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") { + // a handful of distinct codes repeated thousands of times: decode each once + $cur = $memo[$part . '|' . $cur] ??= ansi_color(explode(';', substr($part, 2, -1)), $cur); + continue; + } + $k = $index[$cur ?? ''] ??= count($index); + $c .= $part; + $m .= str_repeat(chr($k), strlen($part)); + } + $chars[] = $c; + $masks[] = $m; + } + if (count($index) > 250) { + return null; + } + + $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); + + $html = ''; + 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) === '') { + continue; // only blanks in this color + } + $style = $color !== '' ? " style=\"color:#$color\"" : ''; + $html .= "" . htmlspecialchars($layer) . ''; + } + return $html; +} + +/** General path: a [char, color] cell at a time, so multibyte characters work. */ +function ansi_html_cells(string $raw, bool $mirror): string { static $flip = ['/' => '\\', '\\' => '/', '(' => ')', ')' => '(', '<' => '>', '>' => '<', '[' => ']', ']' => '[', '{' => '}', '}' => '{']; $lines = []; - foreach (explode("\n", rtrim((string) file_get_contents($file), "\n")) as $line) { + foreach (explode("\n", rtrim($raw, "\n")) as $line) { // split into [char, color] cells $cells = []; $cur = null;