Created
January 2, 2026 08:32
-
-
Save paigeadelethompson/267f0de95ba57150ffc1e3c6e7f3bb8a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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