Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s

A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
This commit is contained in:
Thomas Faour
2026-07-25 14:21:52 +00:00
parent f1fda9bdee
commit 3735c5bfa7
23 changed files with 1062 additions and 13 deletions
+2 -1
View File
@@ -36,7 +36,7 @@ Each module in this package exposes:
from __future__ import annotations
from . import calendar, photos, static_image, tasks, whiteboard
from . import calendar, photos, static_image, tasks, text, whiteboard
WIDGET_TYPES = {
"photos": photos,
@@ -44,4 +44,5 @@ WIDGET_TYPES = {
"whiteboard": whiteboard,
"tasks": tasks,
"static": static_image,
"text": text,
}
+200
View File
@@ -0,0 +1,200 @@
"""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/NotoSans-*.ttf,
OFL-licensed like the emoji fonts already there) rather than every other
widget's single ImageFont.load_default() -- the one widget type where
that distinction is the whole point.
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"
_FONT_FILES = {
(False, False): "NotoSans-Regular.ttf",
(True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf",
(True, True): "NotoSans-BoldItalic.ttf",
}
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
@lru_cache(maxsize=128)
def _font(bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / _FONT_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], size: int) -> float:
return sum(draw.textlength(tok["text"], font=_font(tok["bold"], tok["italic"], size)) for tok in group)
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], 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, 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]], 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(False, False, size))
lines = []
for paragraph in paragraphs:
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), 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,
size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
line_width = sum(_group_width(draw, g, 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(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)
max_width = max(10, target_w - 2 * MARGIN)
max_height = max(10, target_h - 2 * MARGIN)
size, lines = _fit(draw, cfg.content or [], cfg.font_size, max_width, max_height)
line_h = round(size * LINE_HEIGHT_FACTOR)
space_width = draw.textlength(" ", font=_font(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, 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()