Fix emoji/wide-char palette proportions

Emoji, hieroglyphs, dominos, mahjong, playing cards, cuneiform,
alchemical, flora, and weather palettes use double-width characters
that display at 2x normal monospace width in browsers.

Fix: detect wide palettes and halve the render width with adjusted
aspect ratio (1.0 instead of 0.55) so the output maintains correct
image proportions regardless of character width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 04:22:15 +00:00
parent 16a5dbca11
commit 3176a36ce6
1 changed files with 32 additions and 9 deletions

View File

@ -57,13 +57,24 @@ PALETTES = {
MAX_GIF_FRAMES = 60
# Palettes with double-width characters (emoji, CJK supplementary, etc.)
WIDE_PALETTES = {
"emoji", "flora", "weather", "dominos", "mahjong", "playing",
"hieroglyph", "cuneiform", "alchemical",
}
def is_wide_palette(name: str) -> bool:
"""Check if a palette uses double-width (emoji/supplementary) characters."""
return name in WIDE_PALETTES
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:
def _prepare_frame(img: Image.Image, width: int, bg: str, wide_chars: bool = False) -> Image.Image:
"""Composite alpha onto background and resize for rendering."""
# Apply EXIF orientation (phone photos come rotated without this)
img = ImageOps.exif_transpose(img)
@ -72,12 +83,21 @@ def _prepare_frame(img: Image.Image, width: int, bg: str) -> Image.Image:
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
if wide_chars:
# Emoji/wide chars are ~2x the width of normal monospace chars
# so halve the render width to maintain correct proportions,
# and use a squarer aspect since each char cell is nearly square
render_width = max(1, width // 2)
char_aspect = 1.0
else:
render_width = width
# Monospace chars: ~0.55 width-to-height ratio at 5px font, line-height 1.0
char_aspect = 0.55
aspect = img.height / img.width
height = max(1, int(width * aspect * char_aspect))
return img.resize((width, height), Image.LANCZOS)
height = max(1, int(render_width * aspect * char_aspect))
return img.resize((render_width, height), Image.LANCZOS)
def _floyd_steinberg_dither(pixels: list[list[float]], width: int, height: int, levels: int) -> list[list[float]]:
@ -192,7 +212,8 @@ def image_to_art(
) -> str:
"""Convert a static image to colorful Unicode art."""
img = Image.open(image_path)
img = _prepare_frame(img, width, bg)
wide = is_wide_palette(palette_name)
img = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(img, chars, double_width, dither, output_format)
@ -208,6 +229,7 @@ def gif_to_art(
) -> list[dict]:
"""Convert an animated GIF to a list of frame dicts with art and duration."""
img = Image.open(image_path)
wide = is_wide_palette(palette_name)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
frames = []
@ -221,7 +243,7 @@ def gif_to_art(
for frame in frame_list:
duration = frame.info.get("duration", 100)
prepared = _prepare_frame(frame.copy(), width, bg)
prepared = _prepare_frame(frame.copy(), width, bg, wide_chars=wide)
art = _render_frame(prepared, chars, double_width, dither, output_format)
frames.append({"art": art, "duration": max(duration, 20)})
@ -238,7 +260,8 @@ def render_from_pil(
output_format: str = "ansi",
) -> str:
"""Render from a PIL Image object directly."""
prepared = _prepare_frame(img, width, bg)
wide = is_wide_palette(palette_name)
prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(prepared, chars, double_width, dither, output_format)