diff --git a/render.py b/render.py index 6882957..be8b821 100644 --- a/render.py +++ b/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)