Files
espresso_frame/server/app/widgets/text.py
T
tfaour 474b92a282
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00

230 lines
10 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 PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import EPD_HEIGHT, EPD_WIDTH, _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)
# The font-family table (a small curated set, not an open-ended picker --
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
# lives in app/theme_tokens.py now, shared with every modern-style
# widget's own font resolution -- re-exported here under their original
# names since this was the text widget's own table before the theme
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
# family's own license/copyright).
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
_FONT_FILES = theme_tokens._FONT_FILES
_font = theme_tokens.font
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
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_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
"""classic vs modern (app/html_render.py) -- shared by render() and
render_preview_png() so both honor render_style identically (weather
once shipped with its preview endpoint bypassing render_style
entirely by calling the classic renderer directly -- this shared
dispatch point exists specifically so that bug can't happen here).
palette_rgb is unused by the classic path (it never quantizes itself
-- see module docstring), only threaded through for modern's own
ordered_dither. theme_name is threaded through uniformly (every
modern-style widget's dispatch takes one) but build_text ignores it
-- see its own docstring for why (the text widget's font is a
per-widget, user-authored choice, not theme-driven)."""
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
return _render_text(cfg, target_w, target_h)
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_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
theme_name: str | None = None, panel_w: int = EPD_WIDTH,
panel_h: int = EPD_HEIGHT) -> 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, panel_w, panel_h)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()