Skip to content

Instantly share code, notes, and snippets.

@mwunsch
Last active June 25, 2026 16:28
Show Gist options
  • Select an option

  • Save mwunsch/eb79abc6cd2448fc81d85b244f7b971f to your computer and use it in GitHub Desktop.

Select an option

Save mwunsch/eb79abc6cd2448fc81d85b244f7b971f to your computer and use it in GitHub Desktop.
Print a large terminal rendering of an emoji.
#!/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