Skip to content

Instantly share code, notes, and snippets.

@paigeadelethompson
Created January 2, 2026 08:32
Show Gist options
  • Select an option

  • Save paigeadelethompson/267f0de95ba57150ffc1e3c6e7f3bb8a to your computer and use it in GitHub Desktop.

Select an option

Save paigeadelethompson/267f0de95ba57150ffc1e3c6e7f3bb8a to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
ANSI to IRC Art Converter
Converts ANSI art files to UTF-8 with IRC mIRC color codes (16 colors).
Converts 24-bit RGB to closest 16-color match.
"""
import os
import re
import sys
from pathlib import Path
from typing import Optional, List, Tuple
def decode_text(data: bytes) -> str:
"""Decode text, handling CP437 and Unicode."""
# Try CP437 first (common for ANSI art)
try:
return data.decode('cp437')
except (UnicodeDecodeError, LookupError):
pass
# Try UTF-8
try:
return data.decode('utf-8')
except UnicodeDecodeError:
pass
# Fallback to latin-1 (preserves all bytes)
return data.decode('latin-1')
# IRC mIRC color palette (standard 16 colors)
# Format: (R, G, B) for colors 0-15
IRC_COLOR_PALETTE = [
(255, 255, 255), # 0: White
(0, 0, 0), # 1: Black
(0, 0, 127), # 2: Blue (Navy)
(0, 147, 0), # 3: Green
(255, 0, 0), # 4: Red
(127, 0, 0), # 5: Brown/Maroon
(156, 0, 156), # 6: Magenta/Purple
(255, 127, 0), # 7: Orange
(255, 255, 0), # 8: Yellow
(0, 252, 0), # 9: Light Green (Lime)
(0, 147, 147), # 10: Cyan (Teal)
(0, 255, 255), # 11: Light Cyan
(0, 0, 252), # 12: Light Blue (Royal)
(255, 0, 255), # 13: Light Magenta (Fuchsia)
(127, 127, 127), # 14: Grey
(210, 210, 210), # 15: Light Grey
]
def rgb_to_irc_color(r: int, g: int, b: int) -> int:
"""Convert 24-bit RGB to closest IRC color code (0-15) using perceptual distance."""
min_dist = float('inf')
best_color = 0
for i, (cr, cg, cb) in enumerate(IRC_COLOR_PALETTE):
# Use perceptual color distance (weighted by human eye sensitivity)
# Weights: R=0.3, G=0.59, B=0.11 (approximate)
r_diff = (r - cr) * 0.3
g_diff = (g - cg) * 0.59
b_diff = (b - cb) * 0.11
dist = (r_diff ** 2 + g_diff ** 2 + b_diff ** 2) ** 0.5
if dist < min_dist:
min_dist = dist
best_color = i
return best_color
class ANSIRenderer:
"""Renders ANSI art to a virtual screen."""
def __init__(self, width: int = 80):
self.width = width
self.height = 1000 # Large initial height
self.screen = [[' ' for _ in range(width)] for _ in range(self.height)]
self.colors = [[(None, None) for _ in range(width)]
for _ in range(self.height)]
self.x = 0
self.y = 0
self.fg = (255, 255, 255) # Default white -> IRC color 0
self.bg = (0, 0, 0) # Default black -> IRC color 1
def set_cursor(self, x: int, y: int):
"""Set cursor position."""
self.x = max(0, min(x, self.width - 1))
self.y = max(0, min(y, self.height - 1))
def move_cursor(self, dx: int, dy: int):
"""Move cursor relative."""
self.set_cursor(self.x + dx, self.y + dy)
def write_char(self, char: str):
"""Write character at cursor position."""
if char == '\n':
self.x = 0
self.y += 1
if self.y >= self.height:
self.screen.append([' '] * self.width)
self.colors.append([(None, None)] * self.width)
self.height += 1
elif char == '\r':
self.x = 0
elif char == '\t':
self.x = (self.x // 8 + 1) * 8
if self.x >= self.width:
self.x = 0
self.y += 1
else:
if self.y < self.height and self.x < self.width:
self.screen[self.y][self.x] = char
self.colors[self.y][self.x] = (self.fg, self.bg)
self.x += 1
if self.x >= self.width:
self.x = 0
self.y += 1
if self.y >= self.height:
self.screen.append([' '] * self.width)
self.colors.append([(None, None)] * self.width)
self.height += 1
def set_color(self, fg: Optional[Tuple[int, int, int]] = None,
bg: Optional[Tuple[int, int, int]] = None):
"""Set foreground/background color."""
if fg is not None:
self.fg = fg
if bg is not None:
self.bg = bg
def reset_color(self):
"""Reset to default colors."""
self.fg = (255, 255, 255) # White
self.bg = (0, 0, 0) # Black
def get_output(self) -> List[str]:
"""Get rendered output with 16-color IRC codes."""
lines = []
for y in range(self.height):
line_parts = []
last_fg_irc = None
last_bg_irc = None
for x in range(self.width):
char = self.screen[y][x]
fg, bg = self.colors[y][x]
# Convert 24-bit RGB to IRC colors
fg_irc = rgb_to_irc_color(fg[0], fg[1], fg[2]) if fg else None
bg_irc = rgb_to_irc_color(bg[0], bg[1], bg[2]) if bg else None
# Output color change if needed
if fg_irc != last_fg_irc or bg_irc != last_bg_irc:
# Build IRC color code: \x03FG or \x03FG,BG
if fg_irc is not None and bg_irc is not None:
# Both foreground and background
if fg_irc < 10:
if bg_irc < 10:
color_code = f"\x03{fg_irc:01d},{bg_irc:01d}"
else:
color_code = f"\x03{fg_irc:01d},{bg_irc:02d}"
else:
if bg_irc < 10:
color_code = f"\x03{fg_irc:02d},{bg_irc:01d}"
else:
color_code = f"\x03{fg_irc:02d},{bg_irc:02d}"
elif fg_irc is not None:
# Only foreground
if fg_irc < 10:
color_code = f"\x03{fg_irc:01d}"
else:
color_code = f"\x03{fg_irc:02d}"
elif bg_irc is not None:
# Only background (unusual but handle it)
if bg_irc < 10:
color_code = f"\x03,{bg_irc:01d}"
else:
color_code = f"\x03,{bg_irc:02d}"
else:
# Reset color
color_code = "\x03"
line_parts.append(color_code)
last_fg_irc = fg_irc
last_bg_irc = bg_irc
line_parts.append(char)
line = ''.join(line_parts)
# Preserve trailing spaces for proper alignment
if line.rstrip() or (lines and any(self.screen[y])):
# Reset color at end of line if colors were used
if '\x03' in line and line.rstrip():
line = line.rstrip() + '\x03'
lines.append(line)
# Remove trailing completely empty lines
while lines and not lines[-1].strip() and '\x03' not in lines[-1]:
lines.pop()
return lines
def handle_cursor_command(renderer: ANSIRenderer, cmd: str):
"""Handle cursor positioning commands."""
# Match patterns like "68C" (move right)
match = re.match(r'(\d+)([ABCDEFGHJKST])', cmd)
if not match:
# Try [row;colH format (ANSI uses 1-based indexing)
if ';' in cmd and cmd.endswith('H'):
parts = cmd.rstrip('H').split(';')
if len(parts) == 2:
try:
# Default to 1 if empty (ANSI standard)
row_str = parts[0].strip()
col_str = parts[1].strip()
row = int(row_str) if row_str else 1
col = int(col_str) if col_str else 1
renderer.set_cursor(col - 1, row - 1)
except ValueError:
pass
return
num = int(match.group(1))
direction = match.group(2)
if direction == 'C': # Move right
renderer.move_cursor(num, 0)
elif direction == 'D': # Move left
renderer.move_cursor(-num, 0)
elif direction == 'A': # Move up
renderer.move_cursor(0, -num)
elif direction == 'B': # Move down
renderer.move_cursor(0, num)
elif direction == 'H': # Home
if num == 1:
renderer.set_cursor(0, 0)
else:
renderer.set_cursor(0, num - 1)
# ANSI 16-color palette to RGB
ANSI_16_COLORS = {
0: (0, 0, 0), # Black
1: (128, 0, 0), # Red
2: (0, 128, 0), # Green
3: (128, 128, 0), # Yellow
4: (0, 0, 128), # Blue
5: (128, 0, 128), # Magenta
6: (0, 128, 128), # Cyan
7: (192, 192, 192), # White
8: (128, 128, 128), # Bright Black
9: (255, 0, 0), # Bright Red
10: (0, 255, 0), # Bright Green
11: (255, 255, 0), # Bright Yellow
12: (0, 0, 255), # Bright Blue
13: (255, 0, 255), # Bright Magenta
14: (0, 255, 255), # Bright Cyan
15: (255, 255, 255), # Bright White
}
def parse_ansi_code(renderer: ANSIRenderer, codes: List[int]):
"""Parse ANSI color/attribute codes."""
i = 0
while i < len(codes):
code = codes[i]
if code == 0:
renderer.reset_color()
i += 1
elif code == 1: # Bold
# Make foreground brighter
fg = renderer.fg
renderer.fg = tuple(min(255, c + 64) for c in fg)
i += 1
elif code == 38 and i + 4 < len(codes) and codes[i+1] == 2:
# 24-bit foreground: 38;2;R;G;B
r, g, b = codes[i+2], codes[i+3], codes[i+4]
renderer.set_color(fg=(r, g, b))
i += 5
elif code == 48 and i + 4 < len(codes) and codes[i+1] == 2:
# 24-bit background: 48;2;R;G;B
r, g, b = codes[i+2], codes[i+3], codes[i+4]
renderer.set_color(bg=(r, g, b))
i += 5
elif 30 <= code <= 37:
# 16-color foreground
renderer.set_color(fg=ANSI_16_COLORS[code - 30])
i += 1
elif 90 <= code <= 97:
# Bright 16-color foreground
renderer.set_color(fg=ANSI_16_COLORS[code - 90 + 8])
i += 1
elif 40 <= code <= 47:
# 16-color background
renderer.set_color(bg=ANSI_16_COLORS[code - 40])
i += 1
elif 100 <= code <= 107:
# Bright 16-color background
renderer.set_color(bg=ANSI_16_COLORS[code - 100 + 8])
i += 1
else:
i += 1
def detect_width(text: str) -> int:
"""Detect intended width from cursor positioning."""
widths = []
for match in re.finditer(r'\x1b\[(\d+)C', text):
pos = int(match.group(1))
if pos > 80:
# Estimate width (round up to nearest 10)
est = ((pos // 10) + 1) * 10
widths.append(est)
if widths:
from collections import Counter
return Counter(widths).most_common(1)[0][0]
return 80
def convert_ansi(text: str, width: Optional[int] = None) -> str:
"""Convert ANSI text to IRC format with mIRC colors."""
if width is None:
width = detect_width(text)
renderer = ANSIRenderer(width=width)
i = 0
while i < len(text):
# Look for ANSI escape sequence
if text[i] == '\x1b' and i + 1 < len(text) and text[i+1] == '[':
# Find end of escape sequence
j = i + 2
while j < len(text) and text[j] not in 'ABCDEFGHJKSTfm':
j += 1
if j < len(text):
j += 1
seq = text[i:j]
codes_str = seq[2:].rstrip('m')
# Check if it's a cursor command (doesn't end in 'm')
if not seq.endswith('m'):
handle_cursor_command(renderer, codes_str)
else:
# Parse color codes
codes = []
for part in codes_str.split(';'):
try:
codes.append(int(part))
except ValueError:
pass
if codes:
parse_ansi_code(renderer, codes)
i = j
else:
renderer.write_char(text[i])
i += 1
lines = renderer.get_output()
return '\n'.join(lines)
def convert_file(filepath: Path) -> Optional[str]:
"""Convert an ANSI file."""
try:
with open(filepath, 'rb') as f:
data = f.read()
# Remove SAUCE metadata if present
if b'\x1aSAUCE' in data:
data = data[:data.rindex(b'\x1aSAUCE')]
# Normalize line endings
data = data.replace(b'\r\n', b'\n')
# Decode to text (CP437 -> UTF-8)
text = decode_text(data)
# Convert ANSI to IRC format
return convert_ansi(text)
except Exception:
return None
def find_ansi_files(directory: Path) -> List[Path]:
"""Find all .ans and .ANS files."""
files = []
for root, dirs, filenames in os.walk(directory):
if '__pycache__' in root:
continue
for filename in filenames:
if filename.endswith('.ans') or filename.endswith('.ANS'):
files.append(Path(root) / filename)
return sorted(files)
def main():
"""Main entry point."""
if len(sys.argv) > 1:
path = Path(sys.argv[1])
else:
path = Path.cwd()
if not path.exists():
sys.exit(1)
if path.is_file():
files = [path]
base_dir = path.parent
else:
base_dir = path
files = find_ansi_files(base_dir)
for filepath in files:
rel_path = filepath.relative_to(base_dir)
result = convert_file(filepath)
if result:
# Print filename header to stdout
print(f"=== {rel_path} ===", file=sys.stdout)
# Print art to stdout
print(result, file=sys.stdout)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment