#!/usr/bin/env python3 """ Colorful Unicode/Wingdings ASCII Art Renderer Converts images and GIFs to terminal art using various character sets with true-color ANSI output, Floyd-Steinberg dithering, and multiple output formats. """ import argparse import math import random from html import escape as html_escape from PIL import Image, ImageOps, ImageSequence # ── Character palettes sorted dark → light ────────────────────────────── PALETTES = { "wingdings": "♠♣♦♥✦✧◆◇○●◐◑▲△▼▽★☆✪✫✿❀❁❃❋✾✽❖☀☁☂☃✈♛♚♞♜⚡⚛⚙", "zodiac": "♈♉♊♋♌♍♎♏♐♑♒♓⛎☉☽☿♀♁♂♃♄♅♆⚳⚴⚵⚶⚷", "chess": "♔♕♖♗♘♙♚♛♜♝♞♟⬛⬜◼◻▪▫", "arrows": "↖↗↘↙⇐⇑⇒⇓⟵⟶⟷↺↻⤴⤵↯↮↭↬↫", "music": "♩♪♫♬♭♮♯𝄞𝄡𝄢𝅗𝅥𝅘𝅥𝅘𝅥𝅮𝅘𝅥𝅯𝅘𝅥𝅰", "braille": "⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⣀⣠⣤⣴⣶⣾⣿", "blocks": " ░▒▓█▀▄▌▐▖▗▘▙▚▛▜▝▞▟", "emoji": "🌑🌒🌓🌔🌕✨💫⭐🌟💎🔮🔥💧🌊🌿🍀🌸🌺🌻🎭🎪", "cosmic": "·∙∘○◌◯◎●◉⊙⊚⊛⊜⊝◐◑◒◓◔◕⦿✪★✦✧❂☀☼", "mystic": "·‥…∴∵∶∷∸∹∺⊹✧✦★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋", "runes": "ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯᚰᚱᚲᚳᚴᚵᚶᚷᚸᚹᚺᚻᚼᚽᚾᚿᛀᛁᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋᛌᛍ", "dense": " .·:;+*#%@█", "classic": " .:-=+*#%@", # ── High-resolution palettes (70+ chars for fine gradation) ── "hires": " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$", "ultra": " .·:;'\"^~-_+<>!?|/\\()[]{}1iltfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$█", "dots": "⠀⠁⠈⠐⠠⡀⢀⠃⠅⠆⠉⠊⠌⠑⠒⠔⠘⠡⠢⠤⠨⠰⡁⡂⡄⡈⡐⡠⢁⢂⢄⢈⢐⢠⣀⠇⠋⠍⠎⠓⠕⠖⠙⠚⠜⠣⠥⠦⠩⠪⠬⠱⠲⠴⠸⡃⡅⡆⡉⡊⡌⡑⡒⡔⡘⡡⡢⡤⡨⡰⢃⢅⢆⢉⢊⢌⢑⢒⢔⢘⢡⢢⢤⢨⢰⣁⣂⣄⣈⣐⣠⠏⠗⠛⠝⠞⠧⠫⠭⠮⠳⠵⠶⠹⠺⠼⡇⡋⡍⡎⡓⡕⡖⡙⡚⡜⡣⡥⡦⡩⡪⡬⡱⡲⡴⡸⢇⢋⢍⢎⢓⢕⢖⢙⢚⢜⢣⢥⢦⢩⢪⢬⢱⢲⢴⢸⣃⣅⣆⣉⣊⣌⣑⣒⣔⣘⣡⣢⣤⣨⣰⠟⠯⠷⠻⠽⠾⡏⡗⡛⡝⡞⡧⡫⡭⡮⡳⡵⡶⡹⡺⡼⢏⢗⢛⢝⢞⢧⢫⢭⢮⢳⢵⢶⢹⢺⢼⣇⣋⣍⣎⣓⣕⣖⣙⣚⣜⣣⣥⣦⣩⣪⣬⣱⣲⣴⣸⠿⡟⡯⡷⡻⡽⡾⢟⢯⢷⢻⢽⢾⣏⣗⣛⣝⣞⣧⣫⣭⣮⣳⣵⣶⣹⣺⣼⡿⢿⣟⣯⣷⣻⣽⣾⣿", "shades": " ░░▒▒▓▓██", "geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋", "kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全", # ── Exotic palettes ── "thai": " กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ", "arabic": " ﺎﺏﺕﺙﺝﺡﺥﺩﺫﺭﺯﺱﺵﺹﺽﻁﻅﻉﻍﻑﻕﻙﻝﻡﻥﻩﻭﻱ", "devanagari": " ँंःअआइईउऊऋएऐओऔकखगघचछजझटठडढणतथदधनपफबभमयरलवशषसह", "ethiopic": " ሀሁሂሃሄህሆለሉሊላሌልሎሐሑሒሓሔሕሖመሙሚማሜምሞሰሱሲሳሴስሶረሩሪራሬርሮሸሹሺሻሼሽሾቀቁቂቃቄቅቆበቡቢባቤብቦተቱቲታቴትቶ", "georgian": " აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ", "tibetan": " ༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗༘༙༚༛༜༝༞༟༠༡༢༣༤༥༦༧༨༩", "hieroglyph": " 𓀀𓀁𓀂𓀃𓀄𓀅𓀆𓀇𓀈𓀉𓀊𓀋𓀌𓀍𓀎𓀏𓀐𓀑𓀒𓀓𓀔𓀕𓀖𓀗𓀘𓀙𓀚𓀛𓀜𓀝𓀞𓀟𓀠𓀡𓀢𓀣𓀤𓀥𓀦𓀧𓀨𓀩𓀪𓀫𓀬𓀭𓀮𓀯𓀰", "cuneiform": " 𒀀𒀁𒀂𒀃𒀄𒀅𒀆𒀇𒀈𒀉𒀊𒀋𒀌𒀍𒀎𒀏𒀐𒀑𒀒𒀓𒀔𒀕𒀖𒀗𒀘𒀙𒀚𒀛𒀜𒀝𒀞𒀟𒀠𒀡𒀢𒀣𒀤𒀥𒀦𒀧𒀨𒀩𒀪𒀫𒀬𒀭", "alchemical": " 🜀🜁🜂🜃🜄🜅🜆🜇🜈🜉🜊🜋🜌🜍🜎🜏🜐🜑🜒🜓🜔🜕🜖🜗🜘🜙🜚🜛🜜🜝🜞🜟🜠🜡🜢🜣🜤🜥🜦🜧🜨🜩🜪🜫🜬🜭🜮🜯🜰🜱🜲🜳🜴🜵🜶🜷🜸🜹🜺🜻🜼🜽🜾🜿", "dominos": " 🁣🁤🁥🁦🁧🁨🁩🁪🁫🁬🁭🁮🁯🁰🁱🁲🁳🁴🁵🁶🁷🁸🁹🁺🁻🁼🁽🁾🁿🂀🂁🂂🂃🂄🂅🂆🂇🂈🂉🂊🂋🂌🂍🂎🂏🂐🂑🂒🂓", "mahjong": " 🀀🀁🀂🀃🀄🀅🀆🀇🀈🀉🀊🀋🀌🀍🀎🀏🀐🀑🀒🀓🀔🀕🀖🀗🀘🀙🀚🀛🀜🀝🀞🀟🀠🀡", "dingbats": " ✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❘❙❚❛❜❝❞", "playing": " 🂡🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂬🂭🂮🂱🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂼🂽🂾🃁🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃌🃍🃎🃑🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃜🃝🃞", "yijing": " ☰☱☲☳☴☵☶☷䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿", "box": " ─│┌┐└┘├┤┬┴┼╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳", "math": " ∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅", "flora": " 🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓", "weather": " 🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠", } MAX_GIF_FRAMES = 60 def get_luminance(r: int, g: int, b: int) -> float: """Perceived luminance (0-255).""" return 0.299 * r + 0.587 * g + 0.114 * b def _prepare_frame(img: Image.Image, width: int, bg: str) -> Image.Image: """Composite alpha onto background and resize for rendering.""" # Apply EXIF orientation (phone photos come rotated without this) img = ImageOps.exif_transpose(img) img = img.convert("RGBA") bg_color = (0, 0, 0, 255) if bg == "dark" else (255, 255, 255, 255) background = Image.new("RGBA", img.size, bg_color) background.paste(img, mask=img.split()[3]) img = background.convert("RGB") # Monospace chars in browser: ~0.6 width-to-height ratio at font-size 8px, line-height 1.05 # So each char cell is roughly 4.8px wide x 8.4px tall → ratio 0.57 char_aspect = 0.55 aspect = img.height / img.width height = max(1, int(width * aspect * char_aspect)) return img.resize((width, height), Image.LANCZOS) def _floyd_steinberg_dither(pixels: list[list[float]], width: int, height: int, levels: int) -> list[list[float]]: """Apply Floyd-Steinberg dithering to a 2D luminance array.""" step = 255.0 / max(levels - 1, 1) for y in range(height): for x in range(width): old = pixels[y][x] new = round(old / step) * step new = max(0.0, min(255.0, new)) pixels[y][x] = new err = old - new if x + 1 < width: pixels[y][x + 1] += err * 7 / 16 if y + 1 < height: if x - 1 >= 0: pixels[y + 1][x - 1] += err * 3 / 16 pixels[y + 1][x] += err * 5 / 16 if x + 1 < width: pixels[y + 1][x + 1] += err * 1 / 16 return pixels def _render_frame( img: Image.Image, chars: str, double_width: bool = False, dither: bool = False, output_format: str = "ansi", ) -> str: """Render a single prepared (resized RGB) frame to art string.""" width, height = img.size # Build luminance grid lum_grid = [] for y in range(height): row = [] for x in range(width): r, g, b = img.getpixel((x, y)) row.append(get_luminance(r, g, b)) lum_grid.append(row) if dither: lum_grid = _floyd_steinberg_dither(lum_grid, width, height, len(chars)) lines = [] for y in range(height): line = [] for x in range(width): r, g, b = img.getpixel((x, y)) lum = max(0.0, min(255.0, lum_grid[y][x])) idx = int(lum / 256 * len(chars)) idx = min(idx, len(chars) - 1) char = chars[idx] if double_width: char = char + " " if output_format == "ansi": line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m") elif output_format == "html": line.append(f'{html_escape(char)}') else: # plain line.append(char) lines.append("".join(line)) return "\n".join(lines) def image_to_art( image_path: str, width: int = 80, palette_name: str = "wingdings", bg: str = "dark", double_width: bool = False, dither: bool = False, output_format: str = "ansi", ) -> str: """Convert a static image to colorful Unicode art.""" img = Image.open(image_path) img = _prepare_frame(img, width, bg) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) return _render_frame(img, chars, double_width, dither, output_format) def gif_to_art( image_path: str, width: int = 80, palette_name: str = "wingdings", bg: str = "dark", double_width: bool = False, dither: bool = False, output_format: str = "ansi", ) -> list[dict]: """Convert an animated GIF to a list of frame dicts with art and duration.""" img = Image.open(image_path) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) frames = [] frame_list = list(ImageSequence.Iterator(img)) # Sample evenly if too many frames if len(frame_list) > MAX_GIF_FRAMES: step = len(frame_list) / MAX_GIF_FRAMES indices = [int(i * step) for i in range(MAX_GIF_FRAMES)] frame_list = [frame_list[i] for i in indices] for frame in frame_list: duration = frame.info.get("duration", 100) prepared = _prepare_frame(frame.copy(), width, bg) art = _render_frame(prepared, chars, double_width, dither, output_format) frames.append({"art": art, "duration": max(duration, 20)}) return frames def render_from_pil( img: Image.Image, width: int = 80, palette_name: str = "wingdings", bg: str = "dark", double_width: bool = False, dither: bool = False, output_format: str = "ansi", ) -> str: """Render from a PIL Image object directly.""" prepared = _prepare_frame(img, width, bg) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) return _render_frame(prepared, chars, double_width, dither, output_format) def is_animated_gif(image_path: str) -> bool: """Check if a file is an animated GIF.""" try: img = Image.open(image_path) return getattr(img, "is_animated", False) except Exception: return False # ── Pattern Generators ────────────────────────────────────────────────── PATTERN_TYPES = [ "plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", "aurora", "lava", "crystals", "fractal_tree", ] def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: """HSV (0-1 range) to RGB (0-255).""" if s == 0: c = int(v * 255) return c, c, c h6 = h * 6.0 i = int(h6) f = h6 - i p = int(v * (1 - s) * 255) q = int(v * (1 - s * f) * 255) t = int(v * (1 - s * (1 - f)) * 255) vi = int(v * 255) if i == 0: return vi, t, p if i == 1: return q, vi, p if i == 2: return p, vi, t if i == 3: return p, q, vi if i == 4: return t, p, vi return vi, p, q def generate_pattern( pattern_type: str = "plasma", width: int = 200, height: int = 80, palette_name: str = "wingdings", output_format: str = "html", seed: int | None = None, ) -> str: """Generate a colorful pattern as Unicode art.""" if seed is None: seed = random.randint(0, 2**31) rng = random.Random(seed) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) num_chars = len(chars) # Random parameters for variety freq1 = rng.uniform(0.02, 0.12) freq2 = rng.uniform(0.01, 0.08) freq3 = rng.uniform(0.03, 0.15) phase1 = rng.uniform(0, math.tau) phase2 = rng.uniform(0, math.tau) phase3 = rng.uniform(0, math.tau) hue_offset = rng.uniform(0, 1) hue_speed = rng.uniform(0.002, 0.02) cx = width * rng.uniform(0.3, 0.7) cy = height * rng.uniform(0.3, 0.7) spirals = rng.randint(2, 8) zoom = rng.uniform(0.5, 3.0) lines = [] for y in range(height): line = [] for x in range(width): # Compute value (0-1) and hue based on pattern type if pattern_type == "plasma": v1 = math.sin(x * freq1 + phase1) v2 = math.sin(y * freq2 + phase2) v3 = math.sin((x + y) * freq3 + phase3) v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1) val = (v1 + v2 + v3 + v4) / 4.0 val = (val + 1) / 2 # normalize to 0-1 hue = (val * 3 + hue_offset + x * hue_speed) % 1.0 sat = 0.7 + 0.3 * math.sin(val * math.pi) bri = 0.4 + 0.6 * val elif pattern_type == "mandelbrot": # Map to complex plane re = (x - width * 0.65) / (width * 0.3) * zoom im = (y - height * 0.5) / (height * 0.5) * zoom c = complex(re, im) z = complex(0, 0) iteration = 0 max_iter = 80 while abs(z) < 4 and iteration < max_iter: z = z * z + c iteration += 1 if iteration == max_iter: val = 0 hue = 0 sat = 0 bri = 0.05 else: val = iteration / max_iter hue = (val * 5 + hue_offset) % 1.0 sat = 0.9 bri = 0.3 + 0.7 * val elif pattern_type == "spiral": dx = x - cx dy = (y - cy) * 2.2 # aspect correction dist = math.sqrt(dx**2 + dy**2) angle = math.atan2(dy, dx) val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2 hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0 sat = 0.8 + 0.2 * math.sin(dist * 0.1) bri = 0.3 + 0.7 * val elif pattern_type == "waves": v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1) v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2) v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1) val = (v1 + v2 + v3 + 3) / 6 hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0 sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2)) bri = 0.3 + 0.7 * val elif pattern_type == "nebula": # Layered noise-like patterns v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2) v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7) v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3) v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2) val = (v1 + v2 + v3 + v4 + 4) / 8 hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0 sat = 0.5 + 0.5 * val bri = 0.1 + 0.9 * val ** 0.7 elif pattern_type == "kaleidoscope": # Mirror and rotate dx = x - cx dy = (y - cy) * 2.2 angle = math.atan2(dy, dx) dist = math.sqrt(dx**2 + dy**2) # Fold angle into segments segments = spirals angle = abs(((angle % (math.tau / segments)) - math.pi / segments)) nx = dist * math.cos(angle) ny = dist * math.sin(angle) v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2) v2 = math.sin(dist * freq3 + phase3) val = (v1 + v2 + 2) / 4 hue = (dist * hue_speed + val + hue_offset) % 1.0 sat = 0.7 + 0.3 * math.sin(val * math.pi) bri = 0.2 + 0.8 * val elif pattern_type == "aurora": wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8 dist_from_wave = abs(y - height * 0.4 - wave) val = max(0, 1 - dist_from_wave / (height * 0.4)) val = val ** 0.5 hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0 sat = 0.6 + 0.4 * val bri = 0.05 + 0.95 * val elif pattern_type == "lava": v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1) v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2) v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3) val = (v1 * v2 + v3 + 2) / 4 val = val ** 1.5 # push towards extremes hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 # reds to oranges sat = 0.8 + 0.2 * val bri = 0.15 + 0.85 * val elif pattern_type == "crystals": # Voronoi-like min_dist = float('inf') min_dist2 = float('inf') # Use fixed seed points for i in range(12): px = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width py = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height d = math.sqrt((x - px)**2 + ((y - py) * 2.2)**2) if d < min_dist: min_dist2 = min_dist min_dist = d elif d < min_dist2: min_dist2 = d # Reset rng state rng = random.Random(seed) _ = [rng.uniform(0, 1) for _ in range(20)] # burn values to resync edge = min_dist2 - min_dist val = min(1, edge / 20) hue = (min_dist * hue_speed + hue_offset) % 1.0 sat = 0.5 + 0.5 * val bri = 0.2 + 0.8 * val elif pattern_type == "fractal_tree": # Barnsley fern inspired nx = (x - cx) / (width * 0.3) ny = (height - y) / (height * 0.8) v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2) v2 = math.sin((nx * ny) * 5 + phase3) v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50) val = (v1 + v2 + v3 + 3) / 6 hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0 # greens sat = 0.5 + 0.5 * val bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8)) else: val = 0.5 hue = 0.5 sat = 0.5 bri = 0.5 r, g, b = _hsv_to_rgb(hue, sat, bri) lum = get_luminance(r, g, b) idx = int(lum / 256 * num_chars) idx = min(idx, num_chars - 1) char = chars[idx] if output_format == "html": line.append(f'{html_escape(char)}') elif output_format == "ansi": line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m") else: line.append(char) lines.append("".join(line)) return "\n".join(lines) def render_demo(image_path: str, width: int = 70): """Render the same image in multiple palettes for comparison.""" from rich.console import Console from rich.panel import Panel console = Console(force_terminal=True, color_system="truecolor", width=width + 10) demos = ["wingdings", "cosmic", "braille", "runes", "mystic", "blocks"] for pal in demos: art = image_to_art(image_path, width=width, palette_name=pal) console.print(Panel( art, title=f"[bold bright_cyan]{pal}[/]", border_style="bright_blue", width=width + 4, )) console.print() if __name__ == "__main__": parser = argparse.ArgumentParser(description="Image to colorful Unicode art") parser.add_argument("image", help="Path to image file") parser.add_argument("-w", "--width", type=int, default=80, help="Output width in chars") parser.add_argument("-p", "--palette", default="wingdings", choices=list(PALETTES.keys()), help="Character palette to use") parser.add_argument("--bg", default="dark", choices=["dark", "light"], help="Background color assumption") parser.add_argument("--double", action="store_true", help="Double-width characters") parser.add_argument("--dither", action="store_true", help="Enable Floyd-Steinberg dithering") parser.add_argument("--format", default="ansi", choices=["ansi", "html", "plain"], help="Output format") parser.add_argument("--demo", action="store_true", help="Render in all palettes") args = parser.parse_args() if args.demo: render_demo(args.image, args.width) elif is_animated_gif(args.image): frames = gif_to_art(args.image, args.width, args.palette, args.bg, args.double, args.dither, args.format) import time try: while True: for frame in frames: print("\033[H\033[J" + frame["art"], flush=True) time.sleep(frame["duration"] / 1000) except KeyboardInterrupt: pass else: print(image_to_art(args.image, args.width, args.palette, args.bg, args.double, args.dither, args.format))