""" 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)