forked from genewildish/Mainline
- Add engine/effects/plugins/figment.py (native pipeline implementation) - Add engine/figment_render.py, engine/figment_trigger.py, engine/themes.py - Add 3 SVG assets in figments/ (Mexican/Aztec motif) - Add engine/display/backends/animation_report.py for debugging - Add engine/pipeline/adapters/frame_capture.py for frame capture - Add test-figment preset to presets.toml - Add cairosvg optional dependency to pyproject.toml - Update EffectPluginStage to support is_overlay attribute (for overlay effects) - Add comprehensive tests: test_figment_effect.py, test_figment_pipeline.py, test_figment_render.py - Remove obsolete test_ui_simple.py - Update TODO.md with test cleanup plan - Refactor test_adapters.py to use real components instead of mocks This completes the figment SVG overlay feature integration using the modern pipeline architecture, avoiding legacy effects_plugins. All tests pass (758 total).
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
"""
|
|
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
|
|
|
|
import os
|
|
import sys
|
|
from io import BytesIO
|
|
|
|
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
|
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
|
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
|
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
|
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
|
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
|
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
|
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
|
break
|
|
|
|
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()
|