|
import sys |
|
import re |
|
import time |
|
import struct |
|
import argparse |
|
import os |
|
import math |
|
from PIL import Image, ImageDraw, ImageFont, ImagePalette |
|
|
|
# Author: g023 - https://github.com/g023 - https://x.com/g023dev |
|
|
|
# --- Constants --- |
|
|
|
# Standard ANSI Colors (0-15) |
|
ANSI_COLORS = [ |
|
(0, 0, 0), # 0: Black |
|
(170, 0, 0), # 1: Red |
|
(0, 170, 0), # 2: Green |
|
(170, 85, 0), # 3: Brown/Yellow |
|
(0, 0, 170), # 4: Blue |
|
(170, 0, 170), # 5: Magenta |
|
(0, 170, 170), # 6: Cyan |
|
(170, 170, 170), # 7: White (Light Gray) |
|
(85, 85, 85), # 8: Bright Black (Dark Gray) |
|
(255, 85, 85), # 9: Bright Red |
|
(85, 255, 85), # 10: Bright Green |
|
(255, 255, 85), # 11: Bright Yellow |
|
(85, 85, 255), # 12: Bright Blue |
|
(255, 85, 255), # 13: Bright Magenta |
|
(85, 255, 255), # 14: Bright Cyan |
|
(255, 255, 255) # 15: Bright White |
|
] |
|
|
|
# Generate 256 Color Palette |
|
def generate_xterm_256_palette(): |
|
palette = list(ANSI_COLORS) |
|
# 6x6x6 Color Cube (16-231) |
|
for r in [0, 95, 135, 175, 215, 255]: |
|
for g in [0, 95, 135, 175, 215, 255]: |
|
for b in [0, 95, 135, 175, 215, 255]: |
|
palette.append((r, g, b)) |
|
# Grayscale Ramp (232-255) |
|
for i in range(24): |
|
v = 8 + i * 10 |
|
palette.append((v, v, v)) |
|
return palette |
|
|
|
XTERM_PALETTE = generate_xterm_256_palette() |
|
|
|
class AnsiParser: |
|
""" |
|
Advanced ANSI Parser that maintains the state of a virtual terminal. |
|
""" |
|
def __init__(self, width=80, height=25, default_fg=7, default_bg=0): |
|
self.width = width |
|
self.height = height |
|
self.default_fg = default_fg |
|
self.default_bg = default_bg |
|
|
|
# Terminal State |
|
self.reset_state() |
|
|
|
# Buffer: List of lists of (char, fg_rgb, bg_rgb, bold, underline, blink) |
|
self.buffer = [] |
|
self.clear_screen() |
|
|
|
def get_rgb(self, color_code): |
|
if isinstance(color_code, tuple): |
|
return color_code |
|
if 0 <= color_code < len(XTERM_PALETTE): |
|
return XTERM_PALETTE[color_code] |
|
return (255, 255, 255) |
|
|
|
def reset_state(self): |
|
self.cursor_x = 0 |
|
self.cursor_y = 0 |
|
self.fg = self.default_fg |
|
self.bg = self.default_bg |
|
self.fg_rgb = self.get_rgb(self.default_fg) |
|
self.bg_rgb = self.get_rgb(self.default_bg) |
|
self.bold = False |
|
self.underline = False |
|
self.blink = False |
|
self.reverse = False |
|
self.saved_cursor = (0, 0) |
|
self.visible = True |
|
# Buffer for partial/streamed escape sequences when parsing in chunks |
|
self._pending = '' |
|
|
|
def clear_screen(self): |
|
# Initialize buffer with empty spaces and default attributes |
|
# buffer entries: (char, fg_rgb, bg_rgb, bold, underline, blink, reverse) |
|
self.buffer = [[(' ', self.fg_rgb, self.bg_rgb, False, False, False, False) |
|
for _ in range(self.width)] for _ in range(self.height)] |
|
|
|
def clear_line(self, row, mode=2): |
|
# mode 0: cursor to end, 1: start to cursor, 2: whole line |
|
if 0 <= row < self.height: |
|
line = self.buffer[row] |
|
start = 0 |
|
end = self.width |
|
|
|
if mode == 0: |
|
start = self.cursor_x |
|
elif mode == 1: |
|
end = self.cursor_x + 1 |
|
|
|
for i in range(start, end): |
|
if 0 <= i < self.width: |
|
line[i] = (' ', self.fg_rgb, self.bg_rgb, False, False, False, False) |
|
|
|
def process_sgr(self, params): |
|
if not params: |
|
params = [0] |
|
|
|
i = 0 |
|
while i < len(params): |
|
code = params[i] |
|
|
|
if code == 0: # Reset |
|
self.fg = self.default_fg |
|
self.bg = self.default_bg |
|
self.fg_rgb = self.get_rgb(self.default_fg) |
|
self.bg_rgb = self.get_rgb(self.default_bg) |
|
self.bold = False |
|
self.underline = False |
|
self.blink = False |
|
self.reverse = False |
|
elif code == 1: |
|
# Bold: many ANSI art setups treat bold as bright color |
|
self.bold = True |
|
if isinstance(self.fg, int) and 0 <= self.fg < 8: |
|
self.fg_rgb = self.get_rgb(self.fg + 8) |
|
elif code == 4: self.underline = True |
|
elif code == 5: self.blink = True |
|
elif code == 7: self.reverse = True |
|
elif code == 22: |
|
# Reset bold |
|
self.bold = False |
|
if isinstance(self.fg, int): |
|
self.fg_rgb = self.get_rgb(self.fg) |
|
elif code == 24: self.underline = False |
|
elif code == 25: self.blink = False |
|
elif code == 27: self.reverse = False |
|
elif 30 <= code <= 37: |
|
# Standard foreground colors (0-7) |
|
self.fg = code - 30 |
|
if self.bold and 0 <= self.fg < 8: |
|
# Map bold to bright variant |
|
self.fg_rgb = self.get_rgb(self.fg + 8) |
|
else: |
|
self.fg_rgb = self.get_rgb(self.fg) |
|
elif code == 38: # Extended FG |
|
if i + 1 < len(params): |
|
mode = params[i+1] |
|
if mode == 5 and i + 2 < len(params): # 256 color |
|
self.fg = params[i+2] |
|
self.fg_rgb = self.get_rgb(self.fg) |
|
i += 2 |
|
elif mode == 2 and i + 4 < len(params): # Truecolor |
|
self.fg = (params[i+2], params[i+3], params[i+4]) |
|
self.fg_rgb = self.fg |
|
i += 4 |
|
elif code == 39: |
|
self.fg = self.default_fg |
|
self.fg_rgb = self.get_rgb(self.fg) |
|
elif 40 <= code <= 47: |
|
self.bg = code - 40 |
|
self.bg_rgb = self.get_rgb(self.bg) |
|
elif code == 48: # Extended BG |
|
if i + 1 < len(params): |
|
mode = params[i+1] |
|
if mode == 5 and i + 2 < len(params): # 256 color |
|
self.bg = params[i+2] |
|
self.bg_rgb = self.get_rgb(self.bg) |
|
i += 2 |
|
elif mode == 2 and i + 4 < len(params): # Truecolor |
|
self.bg = (params[i+2], params[i+3], params[i+4]) |
|
self.bg_rgb = self.bg |
|
i += 4 |
|
elif code == 49: |
|
self.bg = self.default_bg |
|
self.bg_rgb = self.get_rgb(self.bg) |
|
elif 90 <= code <= 97: |
|
self.fg = code - 90 + 8 # Bright FG |
|
self.fg_rgb = self.get_rgb(self.fg) |
|
elif 100 <= code <= 107: |
|
self.bg = code - 100 + 8 # Bright BG |
|
self.bg_rgb = self.get_rgb(self.bg) |
|
|
|
i += 1 |
|
|
|
def parse_data(self, data): |
|
""" |
|
Parses a chunk of ANSI data and updates the terminal state. |
|
""" |
|
# Prepend any pending partial escape sequence from previous chunk |
|
if getattr(self, '_pending', ''): |
|
data = self._pending + data |
|
self._pending = '' |
|
|
|
i = 0 |
|
length = len(data) |
|
|
|
while i < length: |
|
char = data[i] |
|
|
|
# Handle ESC (\x1b) and single-byte CSI (\x9b) |
|
if char == '\x1b' or char == '\x9b': |
|
# If ESC is last, save pending |
|
if i + 1 >= length: |
|
self._pending = data[i:] |
|
break |
|
|
|
# If ESC, require '[' for CSI; if single-byte CSI, parameters start immediately |
|
if char == '\x1b': |
|
if data[i+1] != '[': |
|
# Not a CSI we handle; skip the ESC and continue |
|
i += 1 |
|
continue |
|
j = i + 2 |
|
else: |
|
j = i + 1 |
|
|
|
# Collect intermediate bytes until final byte (ASCII @-~) |
|
param_str = '' |
|
while j < length and not ('@' <= data[j] <= '~'): |
|
param_str += data[j] |
|
j += 1 |
|
|
|
# Incomplete sequence at end of chunk |
|
if j >= length: |
|
self._pending = data[i:] |
|
break |
|
|
|
cmd = data[j] |
|
|
|
# Private mode (starts with '?') |
|
if param_str.startswith('?'): |
|
params = [int(p) for p in param_str[1:].split(';') if p.isdigit()] |
|
if cmd == 'h': |
|
if 25 in params: self.visible = True |
|
elif cmd == 'l': |
|
if 25 in params: self.visible = False |
|
else: |
|
params = [int(p) if p else 0 for p in param_str.split(';')] if param_str else [] |
|
|
|
# Execute CSI command |
|
if cmd == 'm': |
|
self.process_sgr(params) |
|
elif cmd in ('H', 'f'): |
|
r = params[0] if len(params) > 0 else 1 |
|
c = params[1] if len(params) > 1 else 1 |
|
self.cursor_y = max(0, min(self.height - 1, r - 1)) |
|
self.cursor_x = max(0, min(self.width - 1, c - 1)) |
|
elif cmd == 'A': |
|
n = params[0] if params else 1 |
|
self.cursor_y = max(0, self.cursor_y - n) |
|
elif cmd == 'B': |
|
n = params[0] if params else 1 |
|
self.cursor_y = min(self.height - 1, self.cursor_y + n) |
|
elif cmd == 'C': |
|
n = params[0] if params else 1 |
|
self.cursor_x = min(self.width - 1, self.cursor_x + n) |
|
elif cmd == 'D': |
|
n = params[0] if params else 1 |
|
self.cursor_x = max(0, self.cursor_x - n) |
|
elif cmd == 'J': |
|
mode = params[0] if params else 0 |
|
if mode == 2: |
|
self.clear_screen() |
|
self.cursor_x = 0 |
|
self.cursor_y = 0 |
|
elif mode == 0: |
|
self.clear_line(self.cursor_y, 0) |
|
for r in range(self.cursor_y + 1, self.height): |
|
self.clear_line(r, 2) |
|
elif mode == 1: |
|
for r in range(0, self.cursor_y): |
|
self.clear_line(r, 2) |
|
self.clear_line(self.cursor_y, 1) |
|
elif cmd == 'K': |
|
mode = params[0] if params else 0 |
|
self.clear_line(self.cursor_y, mode) |
|
elif cmd == 's': |
|
self.saved_cursor = (self.cursor_x, self.cursor_y) |
|
elif cmd == 'u': |
|
self.cursor_x, self.cursor_y = self.saved_cursor |
|
|
|
i = j + 1 |
|
continue |
|
|
|
# Control characters |
|
if char == '\n': |
|
# Treat LF as newline + carriage return (move to column 0) for ANSI art compatibility |
|
self.cursor_x = 0 |
|
self.cursor_y += 1 |
|
if self.cursor_y >= self.height: |
|
self.buffer.pop(0) |
|
self.buffer.append([(' ', self.fg_rgb, self.bg_rgb, False, False, False, False) for _ in range(self.width)]) |
|
self.cursor_y = self.height - 1 |
|
|
|
elif char == '\x08': # Backspace |
|
# Move cursor one position left (and optionally clear) |
|
self.cursor_x = max(0, self.cursor_x - 1) |
|
elif char == '\r': |
|
self.cursor_x = 0 |
|
elif char == '\t': |
|
self.cursor_x = (self.cursor_x // 8 + 1) * 8 |
|
if self.cursor_x >= self.width: |
|
self.cursor_x = self.width - 1 |
|
elif char == '\x0c': |
|
self.clear_screen() |
|
self.cursor_x = 0 |
|
self.cursor_y = 0 |
|
elif ord(char) >= 32: |
|
if 0 <= self.cursor_x < self.width and 0 <= self.cursor_y < self.height: |
|
self.buffer[self.cursor_y][self.cursor_x] = (char, self.fg_rgb, self.bg_rgb, self.bold, self.underline, self.blink, self.reverse) |
|
self.cursor_x += 1 |
|
if self.cursor_x >= self.width: |
|
self.cursor_x = 0 |
|
self.cursor_y += 1 |
|
if self.cursor_y >= self.height: |
|
self.buffer.pop(0) |
|
self.buffer.append([(' ', self.fg_rgb, self.bg_rgb, False, False, False, False) for _ in range(self.width)]) |
|
self.cursor_y = self.height - 1 |
|
|
|
i += 1 |
|
|
|
class AnsiToGifConverter: |
|
def __init__(self, font_path=None, font_size=16, width=80, height=25, baud_rate=14400): |
|
self.parser = AnsiParser(width, height) |
|
self.font_size = font_size |
|
self.baud_rate = baud_rate |
|
|
|
# Load Font |
|
font_loaded = False |
|
if font_path: |
|
try: |
|
self.font = ImageFont.truetype(font_path, font_size) |
|
font_loaded = True |
|
except: |
|
pass |
|
|
|
if not font_loaded: |
|
# Try system fonts (prioritize ones with block characters) |
|
font_candidates = [] |
|
if os.name == 'nt': # Windows |
|
font_candidates = [ |
|
# "Px437_IBM_VGA_8x16.ttf", # CP437 font with all chars including blocks (download if needed) |
|
# now for terminal |
|
# "C:/Windows/Fonts/lucon.ttf" # Lucida Console |
|
"C:/Windows/Fonts/consola.ttf", # Consolas # seems to work good enough |
|
# "C:/Windows/Fonts/cour.ttf", # Courier New |
|
] |
|
else: # Linux/Mac |
|
font_candidates = [ |
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
|
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", |
|
"/System/Library/Fonts/Monaco.dfont" # Mac |
|
] |
|
|
|
for candidate in font_candidates: |
|
try: |
|
self.font = ImageFont.truetype(candidate, font_size) |
|
print(f"Loaded font: {candidate}") |
|
font_loaded = True |
|
break |
|
except: |
|
pass |
|
|
|
if not font_loaded: |
|
print("Warning: Could not load a monospace font. Using default (may not be monospace, rendering may be incorrect).") |
|
self.font = ImageFont.load_default() |
|
|
|
# Calculate Character Size |
|
# Use 'W' for width (widest), '█' for height (includes blocks) |
|
bbox_w = self.font.getbbox("W") |
|
bbox_block = self.font.getbbox("█") |
|
self.char_w = int(bbox_w[2] - bbox_w[0]) |
|
self.char_h = int(bbox_block[3] - bbox_block[1]) |
|
# Ensure minimum size |
|
if self.char_w < 1: self.char_w = 8 |
|
if self.char_h < 1: self.char_h = 16 |
|
# Add minimal padding to prevent row overlap |
|
self.line_height = self.char_h + 2 |
|
|
|
self.img_w = width * self.char_w |
|
self.img_h = height * self.line_height |
|
|
|
def get_color_rgb(self, color_code, is_fg=True): |
|
# Handle Truecolor |
|
if isinstance(color_code, tuple): |
|
return color_code |
|
|
|
# Handle Palette |
|
if 0 <= color_code < len(XTERM_PALETTE): |
|
return XTERM_PALETTE[color_code] |
|
|
|
return (255, 255, 255) if is_fg else (0, 0, 0) |
|
|
|
def render_frame(self, frame_idx=0): |
|
img = Image.new('RGB', (self.img_w, self.img_h), self.get_color_rgb(self.parser.default_bg, False)) |
|
draw = ImageDraw.Draw(img) |
|
|
|
# Blink state: on for 10 frames, off for 10 frames (at 20fps = 0.5s) |
|
blink_on = (frame_idx // 10) % 2 == 0 |
|
|
|
for y in range(self.parser.height): |
|
for x in range(self.parser.width): |
|
char, fg_rgb, bg_rgb, bold, underline, blink, reverse = self.parser.buffer[y][x] |
|
|
|
# Determine effective foreground/background based on reverse flag |
|
if reverse: |
|
cell_fg = bg_rgb |
|
cell_bg = fg_rgb |
|
else: |
|
cell_fg = fg_rgb |
|
cell_bg = bg_rgb |
|
|
|
# Draw background always (handles non-black backgrounds and reverse) |
|
draw.rectangle( |
|
[x * self.char_w, y * self.line_height, (x + 1) * self.char_w, (y + 1) * self.line_height], |
|
fill=cell_bg |
|
) |
|
|
|
# Foreground |
|
if blink and not blink_on: |
|
continue # Don't draw text if blinking and in off state |
|
|
|
# Special handling for block characters (render as solid/patterned shapes) |
|
cell_x = x * self.char_w |
|
cell_y = y * self.line_height |
|
cell_w = self.char_w |
|
cell_h = self.line_height |
|
|
|
if char == '█': |
|
draw.rectangle([cell_x, cell_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg) |
|
elif char in '▓▒░': |
|
# Shade blocks: draw a dot-grid pattern to approximate density |
|
def _draw_shade(shade_char): |
|
# grid size |
|
cols = max(2, cell_w // 4) |
|
rows = max(2, cell_h // 4) |
|
for gy in range(rows): |
|
for gx in range(cols): |
|
if shade_char == '░': |
|
cond = ((gx + gy) % 4) == 0 |
|
elif shade_char == '▒': |
|
cond = ((gx + gy) % 2) == 0 |
|
else: # '▓' |
|
cond = ((gx + gy) % 4) != 0 |
|
|
|
if cond: |
|
px = cell_x + gx * (cell_w / cols) |
|
py = cell_y + gy * (cell_h / rows) |
|
pw = int(math.ceil(cell_w / cols)) |
|
ph = int(math.ceil(cell_h / rows)) |
|
draw.rectangle([px, py, px + pw, py + ph], fill=cell_fg) |
|
|
|
_draw_shade(char) |
|
elif char in '▄▀▌▐': |
|
# Half blocks: draw half rectangles using cell dimensions |
|
mid_y = cell_y + (cell_h // 2) |
|
mid_x = cell_x + (cell_w // 2) |
|
if char == '▄': |
|
draw.rectangle([cell_x, mid_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg) |
|
elif char == '▀': |
|
draw.rectangle([cell_x, cell_y, cell_x + cell_w, mid_y], fill=cell_fg) |
|
elif char == '▌': |
|
draw.rectangle([cell_x, cell_y, mid_x, cell_y + cell_h], fill=cell_fg) |
|
elif char == '▐': |
|
draw.rectangle([mid_x, cell_y, cell_x + cell_w, cell_y + cell_h], fill=cell_fg) |
|
elif char != ' ': |
|
# Draw regular character; simulate bold by overdrawing slightly if needed |
|
y_draw = cell_y + getattr(self, 'y_offset', 0) |
|
if bold: |
|
# Draw a thicker glyph by drawing twice with a 1px offset |
|
draw.text((cell_x + 1, y_draw), char, font=self.font, fill=cell_fg) |
|
draw.text((cell_x, y_draw), char, font=self.font, fill=cell_fg) |
|
|
|
if underline: |
|
draw.line( |
|
[x * self.char_w, (y + 1) * self.line_height - 2, (x + 1) * self.char_w, (y + 1) * self.line_height - 2], |
|
fill=fg_rgb, width=1 |
|
) |
|
|
|
# Draw Cursor |
|
if self.parser.visible and blink_on: |
|
cx = self.parser.cursor_x |
|
cy = self.parser.cursor_y |
|
if 0 <= cx < self.parser.width and 0 <= cy < self.parser.height: |
|
cursor_color = self.parser.fg_rgb |
|
draw.rectangle( |
|
[cx * self.char_w, (cy + 1) * self.line_height - 4, (cx + 1) * self.char_w, (cy + 1) * self.line_height - 1], |
|
fill=cursor_color |
|
) |
|
|
|
return img |
|
|
|
def parse_sauce(self, data): |
|
# SAUCE record is 128 bytes at end of file |
|
# ID: "SAUCE" (5 bytes) |
|
# Version: "00" (2 bytes) |
|
# Title: 35 bytes |
|
# Author: 20 bytes |
|
# Group: 20 bytes |
|
# Date: 8 bytes (YYYYMMDD) |
|
# FileSize: 4 bytes (unsigned int) |
|
# DataType: 1 byte |
|
# FileType: 1 byte |
|
# TInfo1: 2 bytes |
|
# TInfo2: 2 bytes |
|
# TInfo3: 2 bytes |
|
# TInfo4: 2 bytes |
|
# Comments: 1 byte |
|
# Flags: 1 byte |
|
# Filler: 22 bytes |
|
|
|
if len(data) >= 128: |
|
sauce_rec = data[-128:] |
|
if sauce_rec[:5] == b'SAUCE': |
|
title = sauce_rec[7:42].decode('cp437', 'ignore').strip() |
|
author = sauce_rec[42:62].decode('cp437', 'ignore').strip() |
|
group = sauce_rec[62:82].decode('cp437', 'ignore').strip() |
|
print(f"SAUCE Record Found: Title='{title}', Author='{author}', Group='{group}'") |
|
|
|
# Extract dimensions if available (TInfo1 = Width, TInfo2 = Height) |
|
# Note: DataType 1 is Character based. |
|
datatype = sauce_rec[94] |
|
if datatype == 1: |
|
w = struct.unpack('<H', sauce_rec[96:98])[0] |
|
h = struct.unpack('<H', sauce_rec[98:100])[0] |
|
if w > 0 and h > 0: |
|
print(f"SAUCE Dimensions: {w}x{h}") |
|
self.parser.width = w |
|
self.parser.height = h |
|
self.parser.clear_screen() # Resize buffer |
|
self.img_w = w * self.char_w |
|
self.img_h = h * self.char_h |
|
return True |
|
return False |
|
|
|
def convert(self, input_data, output_file, fps=20): |
|
# Check for SAUCE |
|
has_sauce = self.parse_sauce(input_data.encode('cp437', 'ignore')) |
|
|
|
# Remove SAUCE from data if present to avoid rendering it |
|
if has_sauce: |
|
# The SAUCE record is strictly the last 128 bytes. |
|
# Sometimes there is a EOF char before it. |
|
# We'll just process the whole thing, usually SAUCE is not valid ANSI so it might look like garbage or be ignored. |
|
# Better to strip it. |
|
input_data = input_data[:-128] |
|
|
|
frames = [] |
|
|
|
# Baud Rate Simulation |
|
# Bytes per second = baud_rate / 10 (8N1 = 10 bits per byte) |
|
bytes_per_sec = self.baud_rate / 10 |
|
bytes_per_frame = int(bytes_per_sec / fps) |
|
if bytes_per_frame < 1: bytes_per_frame = 1 |
|
|
|
total_len = len(input_data) |
|
processed = 0 |
|
|
|
print(f"Converting... Total bytes: {total_len}, Bytes/Frame: {bytes_per_frame}") |
|
|
|
while processed < total_len: |
|
chunk = input_data[processed : processed + bytes_per_frame] |
|
self.parser.parse_data(chunk) |
|
processed += len(chunk) |
|
|
|
# Capture frame |
|
# Optimization: Only capture if something changed? |
|
# For baud rate simulation, we want to see the drawing process. |
|
frames.append(self.render_frame(len(frames))) |
|
|
|
if processed % (bytes_per_frame * 10) == 0: |
|
print(f"Processed {processed}/{total_len} bytes...") |
|
|
|
# Add a few seconds of pause at the end |
|
for _ in range(fps * 2): |
|
frames.append(self.render_frame(len(frames))) |
|
|
|
print(f"Saving GIF to {output_file} with {len(frames)} frames...") |
|
frames[0].save( |
|
output_file, |
|
save_all=True, |
|
append_images=frames[1:], |
|
duration=1000 // fps, |
|
loop=0, |
|
optimize=True # Use PIL's optimization |
|
) |
|
print("Done!") |
|
|
|
def main(): |
|
parser = argparse.ArgumentParser(description="ANSI to Animated GIF Converter") |
|
parser.add_argument("input_file", help="Path to input ANSI file") |
|
parser.add_argument("output_file", help="Path to output GIF file") |
|
parser.add_argument("--width", type=int, default=80, help="Terminal width (default: 80)") |
|
parser.add_argument("--height", type=int, default=25, help="Terminal height (default: 25)") |
|
parser.add_argument("--baud", type=int, default=14400, help="Simulated Baud Rate (default: 14400)") |
|
parser.add_argument("--fps", type=int, default=20, help="Frames Per Second (default: 20)") |
|
parser.add_argument("--font", type=str, default=None, help="Path to TTF font file") |
|
parser.add_argument("--font-size", type=int, default=18, help="Font size (default: 18)") |
|
|
|
args = parser.parse_args() |
|
|
|
if not os.path.exists(args.input_file): |
|
print(f"Error: Input file '{args.input_file}' not found.") |
|
return |
|
|
|
# Read input with CP437 encoding (standard for ANSI art) |
|
try: |
|
with open(args.input_file, 'r', encoding='cp437') as f: |
|
data = f.read() |
|
except Exception as e: |
|
print(f"Error reading file: {e}") |
|
return |
|
|
|
converter = AnsiToGifConverter( |
|
font_path=args.font, |
|
font_size=args.font_size, |
|
width=args.width, |
|
height=args.height, |
|
baud_rate=args.baud |
|
) |
|
|
|
converter.convert(data, args.output_file, fps=args.fps) |
|
|
|
if __name__ == "__main__": |
|
main() |