Files
sideline/engine/legacy/render.py
David Gwilliam dfe42b0883 refactor(legacy): Create engine/legacy/ subsystem and move render/layers (Phase 3.2)
- Create engine/legacy/ package for deprecated rendering modules
- Move engine/render.py → engine/legacy/render.py (274 lines)
- Move engine/layers.py → engine/legacy/layers.py (272 lines)
- Update engine/legacy/layers.py to import from engine.legacy.render
- Add comprehensive package documentation
- Tests will be updated in next commit (Phase 3.3)
2026-03-16 20:39:30 -07:00

275 lines
9.2 KiB
Python

"""
OTF → terminal half-block rendering pipeline.
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
Depends on: config, terminal, sources, translate.
.. deprecated::
This module contains legacy rendering code. New pipeline code should
use the Stage-based pipeline architecture instead. This module is
maintained for backwards compatibility with the demo mode.
"""
import random
import re
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from engine import config
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
from engine.terminal import RST
from engine.translate import detect_location_language, translate_headline
# ─── GRADIENT ─────────────────────────────────────────────
# Left → right: white-hot leading edge fades to near-black
GRAD_COLS = [
"\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
# Complementary sweep for queue messages (opposite hue family from ticker greens)
MSG_GRAD_COLS = [
"\033[1;38;5;231m", # white
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink
"\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta
"\033[38;5;201m", # bright magenta
"\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta
"\033[38;5;125m", # dark magenta
"\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black
]
# ─── FONT LOADING ─────────────────────────────────────────
_FONT_OBJ = None
_FONT_OBJ_KEY = None
_FONT_CACHE = {}
def font():
"""Lazy-load the primary OTF font (path + face index aware)."""
global _FONT_OBJ, _FONT_OBJ_KEY
if not config.FONT_PATH:
raise FileNotFoundError(
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
)
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
if _FONT_OBJ is None or key != _FONT_OBJ_KEY:
_FONT_OBJ = ImageFont.truetype(
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
)
_FONT_OBJ_KEY = key
return _FONT_OBJ
def clear_font_cache():
"""Reset cached font objects after changing primary font selection."""
global _FONT_OBJ, _FONT_OBJ_KEY
_FONT_OBJ = None
_FONT_OBJ_KEY = None
def load_font_face(font_path, font_index=0, size=None):
"""Load a specific face from a font file or collection."""
font_size = size or config.FONT_SZ
return ImageFont.truetype(font_path, font_size, index=font_index)
def list_font_faces(font_path, max_faces=64):
"""Return discoverable face indexes + display names from a font file."""
faces = []
for idx in range(max_faces):
try:
fnt = load_font_face(font_path, idx)
except Exception:
if idx == 0:
raise
break
family, style = fnt.getname()
display = f"{family} {style}".strip()
if not display:
display = f"{Path(font_path).stem} [{idx}]"
faces.append({"index": idx, "name": display})
return faces
def font_for_lang(lang=None):
"""Get appropriate font for a language."""
if lang is None or lang not in SCRIPT_FONTS:
return font()
if lang not in _FONT_CACHE:
try:
_FONT_CACHE[lang] = ImageFont.truetype(SCRIPT_FONTS[lang], config.FONT_SZ)
except Exception:
_FONT_CACHE[lang] = font()
return _FONT_CACHE[lang]
# ─── RASTERIZATION ────────────────────────────────────────
def render_line(text, fnt=None):
"""Render a line of text as terminal rows using OTF font + half-blocks."""
if fnt is None:
fnt = font()
bbox = fnt.getbbox(text)
if not bbox or bbox[2] <= bbox[0]:
return [""]
pad = 4
img_w = bbox[2] - bbox[0] + pad * 2
img_h = bbox[3] - bbox[1] + pad * 2
img = Image.new("L", (img_w, img_h), 0)
draw = ImageDraw.Draw(img)
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
pix_h = config.RENDER_H * 2
hi_h = pix_h * config.SSAA
scale = hi_h / max(img_h, 1)
new_w_hi = max(1, int(img_w * scale))
img = img.resize((new_w_hi, hi_h), Image.Resampling.LANCZOS)
new_w = max(1, int(new_w_hi / config.SSAA))
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
data = img.tobytes()
thr = 80
rows = []
for y in range(0, pix_h, 2):
row = []
for x in range(new_w):
top = data[y * new_w + x] > thr
bot = data[(y + 1) * new_w + x] > thr 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))
while rows and not rows[-1].strip():
rows.pop()
while rows and not rows[0].strip():
rows.pop(0)
return rows if rows else [""]
def big_wrap(text, max_w, fnt=None):
"""Word-wrap text and render with OTF font."""
if fnt is None:
fnt = font()
words = text.split()
lines, cur = [], ""
for word in words:
test = f"{cur} {word}".strip() if cur else word
bbox = fnt.getbbox(test)
if bbox:
img_h = bbox[3] - bbox[1] + 8
pix_h = config.RENDER_H * 2
scale = pix_h / max(img_h, 1)
term_w = int((bbox[2] - bbox[0] + 8) * scale)
else:
term_w = 0
if term_w > max_w - 4 and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
out = []
for i, ln in enumerate(lines):
out.extend(render_line(ln, fnt))
if i < len(lines) - 1:
out.append("")
return out
def lr_gradient(rows, offset=0.0, grad_cols=None):
"""Color each non-space block character with a shifting left-to-right gradient."""
cols = grad_cols or GRAD_COLS
n = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = []
for row in rows:
if not row.strip():
out.append(row)
continue
buf = []
for x, ch in enumerate(row):
if ch == " ":
buf.append(" ")
else:
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
idx = min(round(shifted * (n - 1)), n - 1)
buf.append(f"{cols[idx]}{ch}{RST}")
out.append("".join(buf))
return out
def lr_gradient_opposite(rows, offset=0.0):
"""Complementary (opposite wheel) gradient used for queue message panels."""
return lr_gradient(rows, offset, MSG_GRAD_COLS)
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
def make_block(title, src, ts, w):
"""Render a headline into a content block with color."""
target_lang = (
(SOURCE_LANGS.get(src) or detect_location_language(title))
if config.MODE == "news"
else None
)
lang_font = font_for_lang(target_lang)
if target_lang:
title = translate_headline(title, target_lang)
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
if target_lang and target_lang in NO_UPPER:
title_up = re.sub(r"\s+", " ", title)
else:
title_up = re.sub(r"\s+", " ", title.upper())
for old, new in [
("\u2019", "'"),
("\u2018", "'"),
("\u201c", '"'),
("\u201d", '"'),
("\u2013", "-"),
("\u2014", "-"),
]:
title_up = title_up.replace(old, new)
big_rows = big_wrap(title_up, w - 4, lang_font)
hc = random.choice(
[
"\033[38;5;46m", # matrix green
"\033[38;5;34m", # dark green
"\033[38;5;82m", # lime
"\033[38;5;48m", # sea green
"\033[38;5;37m", # teal
"\033[38;5;44m", # cyan
"\033[38;5;87m", # sky
"\033[38;5;117m", # ice blue
"\033[38;5;250m", # cool white
"\033[38;5;156m", # pale green
"\033[38;5;120m", # mint
"\033[38;5;80m", # dark cyan
"\033[38;5;108m", # grey-green
"\033[38;5;115m", # sage
"\033[1;38;5;46m", # bold green
"\033[1;38;5;250m", # bold white
]
)
content = [" " + r for r in big_rows]
content.append("")
meta = f"\u2591 {src} \u00b7 {ts}"
content.append(" " * max(2, w - len(meta) - 2) + meta)
return content, hc, len(content) - 1 # (rows, color, meta_row_index)