diff --git a/engine/figment_render.py b/engine/figment_render.py new file mode 100644 index 0000000..4b8b696 --- /dev/null +++ b/engine/figment_render.py @@ -0,0 +1,78 @@ +""" +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() diff --git a/tests/test_figment_render.py b/tests/test_figment_render.py new file mode 100644 index 0000000..4b95acf --- /dev/null +++ b/tests/test_figment_render.py @@ -0,0 +1,47 @@ +"""Tests for engine.figment_render module.""" + +import os + +from engine.figment_render import rasterize_svg + +FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg") + + +class TestRasterizeSvg: + def test_returns_list_of_strings(self): + rows = rasterize_svg(FIXTURE_SVG, 40, 20) + assert isinstance(rows, list) + assert all(isinstance(r, str) for r in rows) + + def test_output_height_matches_terminal_height(self): + rows = rasterize_svg(FIXTURE_SVG, 40, 20) + assert len(rows) == 20 + + def test_output_contains_block_characters(self): + rows = rasterize_svg(FIXTURE_SVG, 40, 20) + all_chars = "".join(rows) + block_chars = {"█", "▀", "▄"} + assert any(ch in all_chars for ch in block_chars) + + def test_different_sizes_produce_different_output(self): + rows_small = rasterize_svg(FIXTURE_SVG, 20, 10) + rows_large = rasterize_svg(FIXTURE_SVG, 80, 40) + assert len(rows_small) == 10 + assert len(rows_large) == 40 + + def test_nonexistent_file_raises(self): + import pytest + with pytest.raises(Exception): + rasterize_svg("/nonexistent/file.svg", 40, 20) + + +class TestRasterizeCache: + def test_cache_returns_same_result(self): + rows1 = rasterize_svg(FIXTURE_SVG, 40, 20) + rows2 = rasterize_svg(FIXTURE_SVG, 40, 20) + assert rows1 == rows2 + + def test_cache_invalidated_by_size_change(self): + rows1 = rasterize_svg(FIXTURE_SVG, 40, 20) + rows2 = rasterize_svg(FIXTURE_SVG, 60, 30) + assert len(rows1) != len(rows2)