"""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()