Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user