|
|
|
@@ -20,12 +20,11 @@ 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 PIL import Image, ImageDraw
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from .. import theme_tokens
|
|
|
|
|
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
|
|
|
|
@@ -40,66 +39,20 @@ 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",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
# 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+")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
@@ -225,7 +178,7 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
|
|
|
|
palette_rgb: list | None) -> Image.Image:
|
|
|
|
|
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
|
|
|
|
@@ -233,14 +186,17 @@ def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
|
|
|
|
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."""
|
|
|
|
|
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)
|
|
|
|
|
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
|
|
|
|
|
return _render_text(cfg, target_w, target_h)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -252,10 +208,11 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|
|
|
|
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)
|
|
|
|
|
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) -> bytes:
|
|
|
|
|
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
|
|
|
|
|
theme_name: str | None = 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
|
|
|
|
@@ -264,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
target_w, target_h = logical_render_size(orientation)
|
|
|
|
|
img = _render_dispatch(cfg, target_w, target_h, palette_rgb)
|
|
|
|
|
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")
|
|
|
|
|