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:
parent
16a5dbca11
commit
3176a36ce6
41
render.py
41
render.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue