""" SVG to half-block terminal art rasterization. Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode. Follows the same pixel-pair approach as engine/render.py for OTF fonts. """ from __future__ import annotations from io import BytesIO import cairosvg from PIL import Image _cache: dict[tuple[str, int, int], list[str]] = {} def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]: """Convert SVG file to list of half-block terminal rows (uncolored). Args: svg_path: Path to SVG file. width: Target terminal width in columns. height: Target terminal height in rows. Returns: List of strings, one per terminal row, containing block characters. """ cache_key = (svg_path, width, height) if cache_key in _cache: return _cache[cache_key] # SVG -> PNG in memory png_bytes = cairosvg.svg2png( url=svg_path, output_width=width, output_height=height * 2, # 2 pixel rows per terminal row ) # PNG -> greyscale PIL image # Composite RGBA onto white background so transparent areas become white (255) # and drawn pixels retain their luminance values. img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA") img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS) background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255)) background.paste(img_rgba, mask=img_rgba.split()[3]) img = background.convert("L") data = img.tobytes() pix_w = width pix_h = height * 2 # White (255) = empty space, dark (< threshold) = filled pixel threshold = 128 # Half-block encode: walk pixel pairs rows: list[str] = [] for y in range(0, pix_h, 2): row: list[str] = [] for x in range(pix_w): top = data[y * pix_w + x] < threshold bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False if top and bot: row.append("█") elif top: row.append("▀") elif bot: row.append("▄") else: row.append(" ") rows.append("".join(row)) _cache[cache_key] = rows return rows def clear_cache() -> None: """Clear the rasterization cache (e.g., on terminal resize).""" _cache.clear()