Last active
June 25, 2026 16:28
-
-
Save mwunsch/eb79abc6cd2448fc81d85b244f7b971f to your computer and use it in GitHub Desktop.
Print a large terminal rendering of an emoji.
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 | |
| """Print a large terminal rendering of an emoji.""" | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| import os | |
| from pathlib import Path | |
| from PIL import Image, ImageDraw, ImageFont | |
| DEFAULT_EMOJI = "\U0001f914" | |
| RESET = "\033[0m" | |
| # Pepto's PAL palette — the widely-cited accurate VIC-II (Commodore 64) colors, | |
| # in hardware color-register order (black, white, red, cyan, ...). | |
| C64_PALETTE = [ | |
| (0, 0, 0), # 0 black | |
| (255, 255, 255), # 1 white | |
| (104, 55, 43), # 2 red | |
| (112, 164, 178), # 3 cyan | |
| (111, 61, 134), # 4 purple | |
| (88, 141, 67), # 5 green | |
| (53, 40, 121), # 6 blue | |
| (184, 199, 111), # 7 yellow | |
| (111, 79, 37), # 8 orange | |
| (67, 57, 0), # 9 brown | |
| (154, 103, 89), # 10 light red | |
| (68, 68, 68), # 11 dark grey | |
| (108, 108, 108), # 12 grey | |
| (154, 210, 132), # 13 light green | |
| (108, 94, 181), # 14 light blue | |
| (149, 149, 149), # 15 light grey | |
| ] | |
| def find_emoji_font() -> str: | |
| candidates = [ | |
| "/System/Library/Fonts/Apple Color Emoji.ttc", | |
| "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", | |
| "/usr/share/fonts/noto-color-emoji/NotoColorEmoji.ttf", | |
| "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf", | |
| ] | |
| for candidate in candidates: | |
| if Path(candidate).exists(): | |
| return candidate | |
| raise SystemExit("emoji-art: could not find a color emoji font") | |
| def load_font(path: str, size: int) -> ImageFont.FreeTypeFont: | |
| # Color emoji fonts often have fixed bitmap strikes. Walk down to a valid one. | |
| for font_size in range(size, 15, -1): | |
| try: | |
| return ImageFont.truetype(path, font_size) | |
| except OSError: | |
| continue | |
| raise SystemExit(f"emoji-art: could not load {path}") | |
| def render_emoji(emoji: str, font_path: str, font_size: int, cols: int, aspect: float = 1.0) -> Image.Image: | |
| font = load_font(font_path, font_size) | |
| probe = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(probe) | |
| left, top, right, bottom = draw.textbbox((0, 0), emoji, font=font, embedded_color=True) | |
| text_width = max(1, right - left) | |
| text_height = max(1, bottom - top) | |
| margin = max(8, font_size // 4) | |
| canvas = Image.new("RGBA", (text_width + margin * 2, text_height + margin * 2), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(canvas) | |
| draw.text((margin - left, margin - top), emoji, font=font, embedded_color=True) | |
| bbox = canvas.getbbox() | |
| if not bbox: | |
| raise SystemExit(f"emoji-art: font rendered an empty glyph for {emoji!r}") | |
| image = canvas.crop(bbox) | |
| pad = max(4, image.width // 18) | |
| padded = Image.new("RGBA", (image.width + pad * 2, image.height + pad * 2), (0, 0, 0, 0)) | |
| padded.alpha_composite(image, (pad, pad)) | |
| width = max(8, cols) | |
| # aspect lets a renderer pre-squash the source; quadrant mode packs two | |
| # subpixels per cell horizontally, so it asks for a 2x-wide, 0.5x-tall image. | |
| height = max(4, round(padded.height * (width / padded.width) * aspect)) | |
| return padded.resize((width, height), Image.Resampling.LANCZOS) | |
| def composite(pixel: tuple[int, int, int, int], bg: tuple[int, int, int]) -> tuple[int, int, int]: | |
| r, g, b, a = pixel | |
| alpha = a / 255 | |
| return ( | |
| round(r * alpha + bg[0] * (1 - alpha)), | |
| round(g * alpha + bg[1] * (1 - alpha)), | |
| round(b * alpha + bg[2] * (1 - alpha)), | |
| ) | |
| def nearest(color: tuple[int, int, int], palette: list[tuple[int, int, int]]) -> tuple[int, int, int]: | |
| return min( | |
| palette, | |
| key=lambda p: (color[0] - p[0]) ** 2 + (color[1] - p[1]) ** 2 + (color[2] - p[2]) ** 2, | |
| ) | |
| def fg(color: tuple[int, int, int]) -> str: | |
| return f"\033[38;2;{color[0]};{color[1]};{color[2]}m" | |
| def bg(color: tuple[int, int, int]) -> str: | |
| return f"\033[48;2;{color[0]};{color[1]};{color[2]}m" | |
| def alpha_at(image: Image.Image, x: int, y: int) -> int: | |
| if y >= image.height: | |
| return 0 | |
| return image.getpixel((x, y))[3] | |
| def pixel(image: Image.Image, x: int, y: int) -> tuple[int, int, int, int]: | |
| if x >= image.width or y >= image.height: | |
| return (0, 0, 0, 0) | |
| return image.getpixel((x, y)) | |
| def print_ansi(image: Image.Image, palette: list[tuple[int, int, int]] | None) -> None: | |
| transparent = (0, 0, 0) | |
| for y in range(0, image.height, 2): | |
| line = [] | |
| for x in range(image.width): | |
| top_a = alpha_at(image, x, y) | |
| bottom_a = alpha_at(image, x, y + 1) | |
| if top_a < 24 and bottom_a < 24: | |
| line.append(RESET + " ") | |
| continue | |
| top = composite(image.getpixel((x, y)), transparent) | |
| bottom = composite(image.getpixel((x, y + 1)) if y + 1 < image.height else (0, 0, 0, 0), transparent) | |
| if palette: | |
| top = nearest(top, palette) | |
| bottom = nearest(bottom, palette) | |
| if top_a >= 24 and bottom_a >= 24: | |
| line.append(fg(top) + bg(bottom) + "\u2580") | |
| elif top_a >= 24: | |
| # Reset first: the transparent lower half must fall back to the | |
| # terminal default, not inherit the previous cell's sticky bg. | |
| line.append(RESET + fg(top) + "\u2580") | |
| else: | |
| line.append(RESET + fg(bottom) + "\u2584") | |
| print("".join(line) + RESET) | |
| def print_ascii(image: Image.Image) -> None: | |
| chars = " .:-=+*#%@" | |
| for y in range(image.height): | |
| line = [] | |
| for x in range(image.width): | |
| r, g, b, a = image.getpixel((x, y)) | |
| if a < 24: | |
| line.append(" ") | |
| continue | |
| luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) * (a / 255) | |
| line.append(chars[min(len(chars) - 1, math.floor(luminance / 256 * len(chars)))]) | |
| print("".join(line).rstrip()) | |
| # 2x2 sub-pixel block glyphs, indexed by a 4-bit mask. | |
| # Bit order: 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right. | |
| QUADRANTS = ( | |
| " ", # 0 ........ | |
| "▘", # 1 TL ▘ | |
| "▝", # 2 TR ▝ | |
| "▀", # 3 TL TR ▀ | |
| "▖", # 4 BL ▖ | |
| "▌", # 5 TL BL ▌ | |
| "▞", # 6 TR BL ▞ | |
| "▛", # 7 TL TR BL ▛ | |
| "▗", # 8 BR ▗ | |
| "▚", # 9 TL BR ▚ | |
| "▐", # 10 TR BR ▐ | |
| "▜", # 11 TL TR BR ▜ | |
| "▄", # 12 BL BR ▄ | |
| "▙", # 13 TL BL BR ▙ | |
| "▟", # 14 TR BL BR ▟ | |
| "█", # 15 TL TR BL BR █ | |
| ) | |
| def _mean(colors: list[tuple[int, int, int]]) -> tuple[int, int, int]: | |
| n = len(colors) | |
| return ( | |
| sum(c[0] for c in colors) // n, | |
| sum(c[1] for c in colors) // n, | |
| sum(c[2] for c in colors) // n, | |
| ) | |
| def _sq(a: tuple[int, int, int], b: tuple[int, int, int]) -> int: | |
| return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2 | |
| def _best_quadrant(colors: list[tuple[int, int, int]]) -> tuple[int, tuple[int, int, int], tuple[int, int, int]]: | |
| """Choose the glyph mask + fg/bg that best reproduce four subpixel colors.""" | |
| best = None | |
| for mask in range(1, 16): # skip 0: the caller only fits non-empty cells | |
| fgc = _mean([colors[i] for i in range(4) if mask & (1 << i)]) | |
| off = [colors[i] for i in range(4) if not (mask & (1 << i))] | |
| bgc = _mean(off) if off else fgc | |
| err = sum(_sq(colors[i], fgc if mask & (1 << i) else bgc) for i in range(4)) | |
| if best is None or err < best[0]: | |
| best = (err, mask, fgc, bgc) | |
| return best[1], best[2], best[3] | |
| def print_quadrants(image: Image.Image, palette: list[tuple[int, int, int]] | None) -> None: | |
| transparent = (0, 0, 0) | |
| for y in range(0, image.height, 2): | |
| line = [] | |
| for x in range(0, image.width, 2): | |
| sub = [ | |
| pixel(image, x, y), pixel(image, x + 1, y), | |
| pixel(image, x, y + 1), pixel(image, x + 1, y + 1), | |
| ] | |
| on = [p[3] >= 24 for p in sub] | |
| if not any(on): | |
| line.append(RESET + " ") | |
| continue | |
| colors = [composite(p, transparent) for p in sub] | |
| if all(on): | |
| mask, fgc, bgc = _best_quadrant(colors) | |
| if palette: | |
| fgc = nearest(fgc, palette) | |
| bgc = nearest(bgc, palette) | |
| if fgc == bgc: # flat cell: one solid color, no background needed | |
| line.append(fg(fgc) + "█") | |
| else: | |
| line.append(fg(fgc) + bg(bgc) + QUADRANTS[mask]) | |
| else: | |
| # Edge cell: opaque subpixels are foreground over the terminal | |
| # default. RESET clears any sticky bg so transparency shows through. | |
| mask = 0 | |
| onc = [] | |
| for i in range(4): | |
| if on[i]: | |
| mask |= 1 << i | |
| onc.append(colors[i]) | |
| fgc = _mean(onc) | |
| if palette: | |
| fgc = nearest(fgc, palette) | |
| line.append(RESET + fg(fgc) + QUADRANTS[mask]) | |
| print("".join(line) + RESET) | |
| def terminal_columns(default: int) -> int: | |
| try: | |
| return min(default, max(24, os.get_terminal_size().columns - 2)) | |
| except OSError: | |
| return default | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| prog="emoji-art", | |
| description="Render any emoji as large ANSI terminal art.", | |
| epilog=( | |
| "examples:\n" | |
| " emoji-art\n" | |
| " emoji-art 😂\n" | |
| " emoji-art 🚀 --c64\n" | |
| " emoji-art \"🏳️🌈\" --cols 48\n" | |
| " emoji-art 🫠 --ascii" | |
| ), | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument("emoji", nargs="?", default=DEFAULT_EMOJI, help="emoji to render; default: 🤔") | |
| parser.add_argument("--cols", type=int, default=64, help="output width in terminal columns") | |
| parser.add_argument("--font-size", type=int, default=160, help="source emoji render size") | |
| parser.add_argument("--font", default=None, help="emoji font path (default: autodetect)") | |
| parser.add_argument("--c64", action="store_true", help="quantize to the accurate Pepto C64 (VIC-II) palette") | |
| parser.add_argument("--ascii", action="store_true", help="use plain ASCII instead of ANSI blocks") | |
| parser.add_argument("--quad", action="store_true", help="chunky 2x2 quadrant blocks (PETSCII-style); pairs with --c64") | |
| args = parser.parse_args() | |
| font_path = args.font or find_emoji_font() | |
| cols = terminal_columns(args.cols) | |
| palette = C64_PALETTE if args.c64 else None | |
| if args.ascii: | |
| print_ascii(render_emoji(args.emoji, font_path, args.font_size, cols)) | |
| elif args.quad: | |
| # 2x wide + 0.5x tall source so two subpixels per cell stay square on screen | |
| image = render_emoji(args.emoji, font_path, args.font_size, cols * 2, aspect=0.5) | |
| print_quadrants(image, palette) | |
| else: | |
| print_ansi(render_emoji(args.emoji, font_path, args.font_size, cols), palette) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment