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.
230 lines
10 KiB
Python
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()
|