""" OTF → terminal half-block rendering pipeline. Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly. Depends on: config, terminal, sources, translate. """ 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)