mirror of
https://github.com/skoelle/28k8-moonweb-org.git
synced 2026-09-17 18:30:24 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user