"""Block rendering core - Font loading, text rasterization, word-wrap, and headline assembly. Provides PIL font-based rendering to terminal half-block characters. """ 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.translate import detect_location_language, translate_headline def estimate_block_height(title: str, width: int, fnt=None) -> int: """Estimate rendered block height without full PIL rendering. Uses font bbox measurement to count wrapped lines, then computes: height = num_lines * RENDER_H + (num_lines - 1) + 2 Args: title: Headline text to measure width: Terminal width in characters fnt: Optional PIL font (uses default if None) Returns: Estimated height in terminal rows """ if fnt is None: fnt = font() text = re.sub(r"\s+", " ", title.upper()) words = text.split() lines = 0 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 max_term_w = width - 4 - 4 if term_w > max_term_w and cur: lines += 1 cur = word else: cur = test if cur: lines += 1 if lines == 0: lines = 1 return lines * config.RENDER_H + max(0, lines - 1) + 2 # ─── 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 # ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── def make_block(title, src, ts, w): """Render a headline into a content block with color. Args: title: Headline text to render src: Source identifier (for metadata) ts: Timestamp string (for metadata) w: Width constraint in terminal characters Returns: tuple: (content_lines, color_code, meta_row_index) - content_lines: List of rendered text lines - color_code: ANSI color code for display - meta_row_index: Row index of metadata line """ 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)