ANSI to IRC converter
Author: Paige Thompson
Intro
This isn't exactly a problem that can be trivially solved and this script is also not exactly perfect (passes on decode errors but easy fix.) The biggest problem is the line length limit (of messages being sent to IRC) as this length includes formatting bytes which guaranteed amounts to at least 80 columns per line + escape codes. Maybe throw this one back into Claude and ask for a better explanation, you should get about the same but perhaps articulated better. This is a tired subject for me and it's also not the first time I've created a script to do this, but it is the first time I've cataloged something that works which happens to also be a script I made with Cursor or Copilot (can't remember.)
#!/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()