forked from genewildish/Mainline
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
265 lines
8.4 KiB
Python
265 lines
8.4 KiB
Python
"""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)
|