forked from genewildish/Mainline
217 lines
7.5 KiB
Python
217 lines
7.5 KiB
Python
"""
|
|
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
|
|
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
|
|
]
|
|
|
|
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
|
MSG_GRAD_COLS = [
|
|
"\033[1;38;5;231m", # white
|
|
"\033[1;38;5;225m", # pale pink-white
|
|
"\033[38;5;219m", # bright pink
|
|
"\033[38;5;213m", # hot pink
|
|
"\033[38;5;207m", # magenta
|
|
"\033[38;5;201m", # bright magenta
|
|
"\033[38;5;165m", # orchid-red
|
|
"\033[38;5;161m", # ruby-magenta
|
|
"\033[38;5;125m", # dark magenta
|
|
"\033[38;5;89m", # deep maroon-magenta
|
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
"\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
|
|
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
|
|
|
|
|
|
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
|
cols = grad_cols or GRAD_COLS
|
|
n = len(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:
|
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
|
idx = min(round(shifted * (n - 1)), n - 1)
|
|
buf.append(f"{cols[idx]}{ch}{RST}")
|
|
out.append("".join(buf))
|
|
return out
|
|
|
|
|
|
def lr_gradient_opposite(rows, offset=0.0):
|
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
|
|
|
|
# ─── 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)
|
|
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)
|