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()