Files
2026-08-15 18:51:43 +02:00

264 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""tools/ansi2html.py - Convert real .ANS (ANSI BBS) files to HTML for the website.
Reads a .ANS file, simulates an 80x25 terminal, and outputs an HTML string
using CSS classes for the 16 ANSI foreground/background colors.
Usage:
python3 tools/ansi2html.py source-assets/ansi/skyline.ans
python3 tools/ansi2html.py source-assets/ansi/skyline.ans --var BANNER_SKYLINE
"""
import sys
import html as html_mod
def cp437_char(byte_val: int) -> str:
"""Convert a CP437 byte value (0-255) to a Unicode character using Python's codec."""
return bytes([byte_val]).decode("cp437")
# ── ANSI 16-color palette → CSS class names ───────────────────────────────────
# FG class: c-fg-N BG class: c-bg-N
# Bold on fg 0-7 brightens to 8-15 (handled in parser).
ANSI_FG_CLASS = {
0: "c-fg-0", 1: "c-fg-1", 2: "c-fg-2", 3: "c-fg-3",
4: "c-fg-4", 5: "c-fg-5", 6: "c-fg-6", 7: "c-fg-7",
8: "c-fg-8", 9: "c-fg-9", 10: "c-fg-10", 11: "c-fg-11",
12: "c-fg-12", 13: "c-fg-13", 14: "c-fg-14", 15: "c-fg-15",
}
ANSI_BG_CLASS = {
0: "c-bg-0", 1: "c-bg-1", 2: "c-bg-2", 3: "c-bg-3",
4: "c-bg-4", 5: "c-bg-5", 6: "c-bg-6", 7: "c-bg-7",
8: "c-bg-8", 9: "c-bg-9", 10: "c-bg-10", 11: "c-bg-11",
12: "c-bg-12", 13: "c-bg-13", 14: "c-bg-14", 15: "c-bg-15",
}
# ── Terminal cell ──────────────────────────────────────────────────────────────
class Cell:
__slots__ = ("char", "fg", "bg", "bold")
def __init__(self, char=" ", fg=7, bg=0, bold=False):
self.char = char
self.fg = fg
self.bg = bg
self.bold = bold
# ── ANSI parser ────────────────────────────────────────────────────────────────
def parse_ansi_to_html(data: bytes, width: int = 80, max_rows: int = 25) -> str:
"""Parse raw ANSI bytes into an HTML string with CSS color classes."""
screen = [[Cell() for _ in range(width)] for _ in range(max_rows)]
cur_row, cur_col = 0, 0
fg, bg, bold = 7, 0, False
i = 0
length = len(data)
while i < length:
b = data[i]
if b == 0x1B: # ESC
i += 1
if i >= length:
break
if data[i] != 0x5B: # '[' — not a CSI sequence
continue
i += 1
# Collect parameter bytes until a final letter
params = []
num_str = ""
while i < length:
c = data[i]
if 0x30 <= c <= 0x39: # '0'-'9'
num_str += chr(c)
i += 1
elif c == 0x3B: # ';'
params.append(int(num_str) if num_str else 0)
num_str = ""
i += 1
elif c == 0x6D: # 'm' — SGR (color/style)
params.append(int(num_str) if num_str else 0)
i += 1
fg, bg, bold = _apply_sgr(params, fg, bg, bold)
break
elif c == 0x48 or c == 0x66: # 'H' or 'f' — cursor position
params.append(int(num_str) if num_str else 0)
i += 1
row = (params[0] if len(params) > 0 else 1) - 1
col = (params[1] if len(params) > 1 else 1) - 1
cur_row = max(0, min(row, max_rows - 1))
cur_col = max(0, min(col, width - 1))
break
elif c == 0x43: # 'C' — cursor forward (right)
n = int(num_str) if num_str else 1
i += 1
cur_col = min(cur_col + n, width - 1)
break
elif c == 0x44: # 'D' — cursor back (left)
n = int(num_str) if num_str else 1
i += 1
cur_col = max(cur_col - n, 0)
break
elif c == 0x4A: # 'J' — erase display
params.append(int(num_str) if num_str else 0)
i += 1
if not params or params[0] in (2, 3):
screen = [[Cell() for _ in range(width)] for _ in range(max_rows)]
break
elif c == 0x4B: # 'K' — erase line
params.append(int(num_str) if num_str else 0)
i += 1
if cur_row < max_rows:
screen[cur_row] = [Cell() for _ in range(width)]
break
elif 0x40 <= c <= 0x7E: # Final byte of unknown CSI
i += 1
break
else:
i += 1
elif b == 0x0D: # CR
i += 1
elif b == 0x0A: # LF
cur_col = 0
cur_row += 1
if cur_row >= max_rows:
cur_row = max_rows - 1
i += 1
elif b == 0x09: # TAB
cur_col = min(cur_col + 8, width - 1)
i += 1
elif b == 0x07: # BEL
i += 1
else:
char = cp437_char(b)
if cur_row < max_rows and cur_col < width:
screen[cur_row][cur_col] = Cell(char, fg, bg, bold)
cur_col += 1
if cur_col >= width:
cur_col = 0
cur_row += 1
if cur_row >= max_rows:
cur_row = max_rows - 1
i += 1
return _render_screen(screen)
def _apply_sgr(params, fg, bg, bold):
"""Apply SGR (Select Graphic Rendition) parameters. Returns (fg, bg, bold)."""
j = 0
while j < len(params):
p = params[j]
if p == 0:
fg, bg, bold = 7, 0, False
elif p == 1:
bold = True
elif p == 2:
bold = False
elif p == 22:
bold = False
elif 30 <= p <= 37:
fg = p - 30
elif p == 39:
fg = 7
elif 40 <= p <= 47:
bg = p - 40
elif p == 49:
bg = 0
elif 90 <= p <= 97:
fg = p - 90 + 8
elif 100 <= p <= 107:
bg = p - 100 + 8
j += 1
return fg, bg, bold
def _render_screen(screen):
"""Render the terminal screen buffer to HTML."""
lines = []
for row in screen:
# Trim trailing spaces (on default bg)
last_non_space = len(row) - 1
while last_non_space >= 0 and row[last_non_space].char == " " and row[last_non_space].bg == 0:
last_non_space -= 1
row = row[:last_non_space + 1]
row_html = ""
seg_fg, seg_bg, seg_bold = None, None, False
seg_chars = []
for cell in row:
effective_fg = 15 if (cell.bold and cell.fg < 8) else cell.fg
key = (effective_fg, cell.bg, cell.bold)
if key != (seg_fg, seg_bg, seg_bold) or not seg_chars:
if seg_chars:
row_html += _span(seg_fg, seg_bg, seg_bold, "".join(seg_chars))
seg_fg, seg_bg, seg_bold = effective_fg, cell.bg, cell.bold
seg_chars = [cell.char]
else:
seg_chars.append(cell.char)
if seg_chars:
row_html += _span(seg_fg, seg_bg, seg_bold, "".join(seg_chars))
lines.append(row_html)
# Trim trailing empty lines
while lines and lines[-1].strip() == "":
lines.pop()
return "\n".join(lines)
def _span(fg, bg, bold, text):
"""Render a styled text segment as an HTML span with CSS classes."""
if not text:
return ""
fg_cls = ANSI_FG_CLASS.get(fg, "c-fg-7")
bg_cls = ANSI_BG_CLASS.get(bg, "c-bg-0")
classes = f"{fg_cls} {bg_cls}"
if bold:
classes += " c-bold"
return f'<span class="{classes}">{html_mod.escape(text)}</span>'
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <file.ans> [--var NAME] [--max-rows N]", file=sys.stderr)
sys.exit(1)
filepath = sys.argv[1]
var_name = None
max_rows = 25
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--var" and i + 1 < len(args):
var_name = args[i + 1]
i += 2
elif args[i] == "--max-rows" and i + 1 < len(args):
max_rows = int(args[i + 1])
i += 2
else:
i += 1
with open(filepath, "rb") as f:
data = f.read()
html_content = parse_ansi_to_html(data, width=80, max_rows=max_rows)
if var_name:
escaped = html_content.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
print(f'export const {var_name} = "<pre class=\\"ansi-text ansi-full\\">{escaped}</pre>";')
else:
print(f'<pre class="ansi-text ansi-full">{html_content}</pre>')
if __name__ == "__main__":
main()