Six more vendored families alongside the existing Noto Sans (Inter, Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/ serif/slab/mono variety), all OFL-licensed with their own per-family license file in app/fonts/ since each has a different copyright holder. Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only families (Inter and Source Sans's current Google Fonts releases, plus Playfair Display/Lora/Merriweather) were skipped in favor of static builds from their own upstream repos, keeping every family's loading code uniform with what was already there. Considered but deliberately left out: Georgia -- a proprietary Microsoft core font, not freely redistributable, unlike everything else vendored here. Also moves the bold/italic/underline/color toolbar below the contenteditable box per request, and reorders the dialog's Settings card to a more natural family-then-size order. Fixes a latent migration bug this surfaced: migration 20 (static image widget) used Base.metadata.create_all, which creates every table declared in Base.metadata that's missing, not just its own new one -- harmless when nothing else pending, but once TextWidgetConfig existed it would silently pre-create text_widget_configs (in whatever shape models.py currently declares) before migration 21 got a turn, so migration 21's own CREATE TABLE (or a later ALTER TABLE adding font_family) would collide with a table create_all had already leaked into existence. Both migrations 20 and 21 now use raw, frozen CREATE TABLE SQL instead, matching migration 17's existing precedent for exactly this reason.
252 lines
11 KiB
Python
252 lines
11 KiB
Python
"""Text widget: user-authored rich text (bold/italic/underline, per-run
|
|
text/highlight color), composed once in the dialog and rendered on
|
|
every panel refresh from the parsed run structure -- no live upstream to
|
|
fetch, same self-contained shape as static_image.py, just word-wrapped
|
|
text instead of an uploaded image. See app/text_content.py for how the
|
|
dialog's contenteditable HTML becomes models.TextWidgetConfig.content
|
|
(the sanitization boundary; this module never sees raw HTML).
|
|
|
|
Bold/italic use real vendored font weights (app/fonts/*.ttf, OFL-
|
|
licensed like the emoji fonts already there) across a small curated set
|
|
of families (FONT_FAMILIES) rather than every other widget's single
|
|
ImageFont.load_default() -- the one widget type where that distinction
|
|
is the whole point. Font family is a whole-widget setting like
|
|
font_size/align, not per-run -- only bold/italic/underline/color/bg
|
|
vary run-to-run (see app/text_content.py).
|
|
|
|
No button actions -- there's nothing to advance/back/check for a fixed
|
|
block of authored text."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
|
from ..models import Frame, TextWidgetConfig, Widget
|
|
from ..text_content import has_text
|
|
from ._shared import placeholder_image
|
|
|
|
ACTIONS: dict = {}
|
|
ACTION_LABELS: dict[str, str] = {}
|
|
|
|
MARGIN = 14
|
|
MIN_FONT_SIZE = 10
|
|
LINE_HEIGHT_FACTOR = 1.35
|
|
DEFAULT_FG = (0, 0, 0)
|
|
DEFAULT_BG = (255, 255, 255)
|
|
|
|
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
|
|
|
# A small curated set, not an open-ended picker -- each entry needs a
|
|
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
|
|
# only ship as a variable font (Playfair Display, Lora, Merriweather,
|
|
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
|
|
# of static builds from their own upstream repos where one exists (see
|
|
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
|
|
# they're all OFL, same as the Noto fonts already vendored here, but
|
|
# each has a different copyright holder so gets its own license file
|
|
# rather than sharing app/fonts/OFL.txt).
|
|
DEFAULT_FONT_FAMILY = "sans"
|
|
FONT_FAMILIES: dict[str, str] = {
|
|
"sans": "Sans-serif (Noto Sans)",
|
|
"inter": "Inter",
|
|
"source_sans": "Source Sans",
|
|
"serif": "Serif (Noto Serif)",
|
|
"elegant": "Elegant serif (Crimson Text)",
|
|
"slab": "Slab serif (Arvo)",
|
|
"mono": "Monospace (IBM Plex Mono)",
|
|
}
|
|
_FONT_FILES = {
|
|
"sans": {
|
|
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
|
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
|
},
|
|
"inter": {
|
|
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
|
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
|
},
|
|
"source_sans": {
|
|
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
|
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
|
},
|
|
"serif": {
|
|
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
|
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
|
},
|
|
"elegant": {
|
|
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
|
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
|
},
|
|
"slab": {
|
|
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
|
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
|
},
|
|
"mono": {
|
|
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
|
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
|
},
|
|
}
|
|
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
|
|
|
|
|
@lru_cache(maxsize=256)
|
|
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
|
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
|
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
|
|
|
|
|
|
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
|
"""One paragraph's styled runs -> word groups: each group is a list
|
|
of same-word sub-tokens that must stay glued together on one line
|
|
(no whitespace between them in the source) -- otherwise bolding part
|
|
of a word (e.g. "wor**ld**") would introduce a spurious space at the
|
|
style boundary once wrapped. Whitespace runs become the implicit gap
|
|
between groups (collapsed to a single space, however many source
|
|
characters it was)."""
|
|
groups: list[list[dict]] = []
|
|
current: list[dict] = []
|
|
for run in paragraph:
|
|
for piece in _WORD_OR_SPACE.findall(run["text"]):
|
|
if piece.isspace():
|
|
if current:
|
|
groups.append(current)
|
|
current = []
|
|
else:
|
|
current.append({**run, "text": piece})
|
|
if current:
|
|
groups.append(current)
|
|
return groups
|
|
|
|
|
|
def _group_width(draw: ImageDraw.ImageDraw, group: list[dict], family: str, size: int) -> float:
|
|
return sum(draw.textlength(tok["text"], font=_font(family, tok["bold"], tok["italic"], size))
|
|
for tok in group)
|
|
|
|
|
|
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], family: str, size: int,
|
|
max_width: int, space_width: float) -> list[list[list[dict]]]:
|
|
"""Greedy word wrap -> list of lines, each a list of word groups.
|
|
An empty `groups` (a blank authored line) still produces one empty
|
|
line, to preserve the blank line's vertical space."""
|
|
lines: list[list[list[dict]]] = []
|
|
current: list[list[dict]] = []
|
|
current_w = 0.0
|
|
for group in groups:
|
|
gw = _group_width(draw, group, family, size)
|
|
add_w = gw + (space_width if current else 0)
|
|
if current and current_w + add_w > max_width:
|
|
lines.append(current)
|
|
current = [group]
|
|
current_w = gw
|
|
else:
|
|
current.append(group)
|
|
current_w += add_w
|
|
if current or not groups:
|
|
lines.append(current)
|
|
return lines
|
|
|
|
|
|
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], family: str, start_size: int,
|
|
max_width: int, max_height: int) -> tuple[int, list[list[list[dict]]]]:
|
|
"""Shrinks font size (down to MIN_FONT_SIZE) until the wrapped
|
|
content's total height fits max_height, or gives up at the floor --
|
|
a too-small widget box just clips rather than raising. Returns the
|
|
chosen size and the flat list of lines (each a list of word groups)
|
|
across every paragraph, in order."""
|
|
size = max(MIN_FONT_SIZE, start_size)
|
|
lines: list[list[list[dict]]] = []
|
|
while True:
|
|
space_width = draw.textlength(" ", font=_font(family, False, False, size))
|
|
lines = []
|
|
for paragraph in paragraphs:
|
|
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), family, size,
|
|
max_width, space_width))
|
|
line_h = round(size * LINE_HEIGHT_FACTOR)
|
|
total_h = len(lines) * line_h
|
|
if total_h <= max_height or size <= MIN_FONT_SIZE:
|
|
return size, lines
|
|
size = max(MIN_FONT_SIZE, size - 2)
|
|
|
|
|
|
def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict]], y: int,
|
|
family: str, size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
|
|
line_width = sum(_group_width(draw, g, family, size) for g in line) + space_width * max(0, len(line) - 1)
|
|
if align == "center":
|
|
x = MARGIN + max(0, (max_width - line_width) / 2)
|
|
elif align == "right":
|
|
x = MARGIN + max(0, max_width - line_width)
|
|
else:
|
|
x = MARGIN
|
|
underline_h = max(1, size // 16)
|
|
for gi, group in enumerate(line):
|
|
for tok in group:
|
|
font = _font(family, tok["bold"], tok["italic"], size)
|
|
w = draw.textlength(tok["text"], font=font)
|
|
if tok["bg"]:
|
|
bg_rgb = hex_to_rgb(tok["bg"])
|
|
if bg_rgb:
|
|
draw.rectangle([x, y, x + w, y + line_h], fill=bg_rgb)
|
|
fill = hex_to_rgb(tok["color"]) if tok["color"] else None
|
|
draw_text(img, (round(x), y), tok["text"], font, fill or DEFAULT_FG)
|
|
if tok["underline"]:
|
|
underline_y = y + font.size + 1
|
|
draw.rectangle([x, underline_y, x + w, underline_y + underline_h], fill=fill or DEFAULT_FG)
|
|
x += w
|
|
if gi < len(line) - 1:
|
|
x += space_width
|
|
|
|
|
|
def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.Image:
|
|
bg = hex_to_rgb(cfg.background_color) or DEFAULT_BG
|
|
img = Image.new("RGB", (target_w, target_h), bg)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
family = cfg.font_family if cfg.font_family in FONT_FAMILIES else DEFAULT_FONT_FAMILY
|
|
max_width = max(10, target_w - 2 * MARGIN)
|
|
max_height = max(10, target_h - 2 * MARGIN)
|
|
size, lines = _fit(draw, cfg.content or [], family, cfg.font_size, max_width, max_height)
|
|
line_h = round(size * LINE_HEIGHT_FACTOR)
|
|
space_width = draw.textlength(" ", font=_font(family, False, False, size))
|
|
|
|
total_h = len(lines) * line_h
|
|
y = MARGIN + max(0, (max_height - total_h) // 2)
|
|
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
|
|
for line in lines:
|
|
if y + line_h > target_h:
|
|
break # ran out of room even at the smallest size -- clip remaining lines rather than overflow
|
|
_draw_line(img, draw, line, y, family, size, line_h, max_width, align, space_width)
|
|
y += line_h
|
|
return img
|
|
|
|
|
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
|
is_normal_wake: bool = True) -> Image.Image:
|
|
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
|
identical note; every widget type's render() shares one call
|
|
signature regardless of which ones actually care."""
|
|
cfg = db.get(TextWidgetConfig, widget.id)
|
|
if cfg is None or not has_text(cfg.content):
|
|
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
|
return _render_text(cfg, target_w, target_h)
|
|
|
|
|
|
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
|
|
"""A normal browser-viewable PNG at full logical panel size --
|
|
mirrors calendar_render.render_tasks_preview_png's relationship to
|
|
render_tasks (the dialog's own preview endpoint always renders at
|
|
the frame's full size, not the widget's actual grid box, same
|
|
convention every other widget type's preview endpoint follows)."""
|
|
import io
|
|
|
|
target_w, target_h = logical_render_size(orientation)
|
|
img = _render_text(cfg, target_w, target_h)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|