diff --git a/engine/render.py b/engine/render.py new file mode 100644 index 0000000..76ee379 --- /dev/null +++ b/engine/render.py @@ -0,0 +1,191 @@ +""" +OTF → terminal half-block rendering pipeline. +Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly. +Depends on: config, terminal, sources, translate. +""" + +import re +import random + +from PIL import Image, ImageDraw, ImageFont + +from engine import config +from engine.terminal import RST, W_COOL +from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER +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 +] + +# ─── FONT LOADING ───────────────────────────────────────── +_FONT_OBJ = None +_FONT_CACHE = {} + + +def font(): + """Lazy-load the primary OTF font.""" + global _FONT_OBJ + if _FONT_OBJ is None: + _FONT_OBJ = ImageFont.truetype(config.FONT_PATH, config.FONT_SZ) + return _FONT_OBJ + + +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 + scale = pix_h / max(img_h, 1) + new_w = max(1, int(img_w * scale)) + 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): + """Color each non-space block character with a left-to-right gradient.""" + n = len(GRAD_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: + idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1) + buf.append(f"{GRAD_COLS[idx]}{ch}\033[0m") + out.append("".join(buf)) + return out + + +# ─── 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) + big_rows = lr_gradient(big_rows) + 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)