"""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 typing import Optional, Tuple from PIL import Image, ImageDraw, ImageFont from sideline.fonts import get_default_font_path, get_default_font_size # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None _FONT_OBJ_KEY = None _FONT_CACHE = {} def font(): """Lazy-load the default Sideline font.""" global _FONT_OBJ, _FONT_OBJ_KEY try: font_path = get_default_font_path() font_size = get_default_font_size() except FileNotFoundError: # Fallback to system default if Sideline font not found return ImageFont.load_default() key = (font_path, font_size) if _FONT_OBJ is None or key != _FONT_OBJ_KEY: try: _FONT_OBJ = ImageFont.truetype(font_path, font_size) _FONT_OBJ_KEY = key except Exception: # If loading fails, fall back to system default _FONT_OBJ = ImageFont.load_default() _FONT_OBJ_KEY = key return _FONT_OBJ def clear_font_cache(): """Reset cached font objects.""" 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.""" if size is None: size = get_default_font_size() return ImageFont.truetype(font_path, 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: Optional[str] = None): """Get appropriate font for a language. Currently uses the default Sideline font for all languages. Language-specific fonts can be added via the font cache system. """ if lang is None: return font() if lang not in _FONT_CACHE: # Try to load language-specific font, fall back to default try: # Could add language-specific font logic here _FONT_CACHE[lang] = font() 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) # Rendering parameters (can be made configurable) render_h = 6 # Terminal rows per rendered line ssaa = 2 # Supersampling anti-aliasing factor pix_h = render_h * 2 hi_h = pix_h * 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 / 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)) return rows def big_wrap(text: str, width: int, fnt=None) -> list[str]: """Wrap text and render to big block characters.""" if fnt is None: fnt = font() text = re.sub(r"\s+", " ", text.upper()) words = text.split() lines = [] cur = "" # Get font size for height calculation try: font_size = fnt.size if hasattr(fnt, "size") else get_default_font_size() except Exception: font_size = get_default_font_size() render_h = 6 # Terminal rows per rendered line 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 = 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.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: str, src: str, ts: str, w: int) -> Tuple[list[str], str, int]: """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 """ # Use default font for all languages (simplified from original) lang_font = font() # Simple uppercase conversion (can be made language-aware later) title_up = re.sub(r"\s+", " ", title.upper()) # Standardize quotes and dashes 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) # Matrix-style color selection 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)