forked from genewildish/Mainline
feat: Implement OTF to terminal half-block rendering pipeline including font loading, text rasterization, word-wrap, and gradient coloring.
This commit is contained in:
191
engine/render.py
Normal file
191
engine/render.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user