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:
+14
-9
@@ -1,8 +1,8 @@
|
||||
# Widget system
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image), like arranging
|
||||
icons on an Android home screen. A frame can hold several widgets of the
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||
by side).
|
||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||
@@ -20,16 +20,20 @@ a button press does.
|
||||
## Data model
|
||||
|
||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"`), `x`/`y`/`w`/`h`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
|
||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
(plus `StaticWidgetConfig`) each keyed by `widget_id` with
|
||||
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
|
||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||
mostly-irrelevant columns. `PhotoWidgetConfig`
|
||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||
text (paragraphs of styled runs), never raw HTML -- see
|
||||
`server/app/text_content.py`'s module docstring for why that parse
|
||||
step is the widget's actual stored-XSS sanitization boundary.
|
||||
`PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
@@ -61,7 +65,7 @@ orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1.
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
||||
Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
@@ -71,7 +75,7 @@ alone.
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||
`static_image.py`), each exposing:
|
||||
`static_image.py`, `text.py`), each exposing:
|
||||
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||
@@ -81,8 +85,9 @@ alone.
|
||||
bad moment doesn't blank the whole panel.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||
for whiteboard). Empty for tasks and static image -- nothing to
|
||||
advance/back/force for a passive checklist or a fixed uploaded image.
|
||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
||||
a fixed block of authored text.
|
||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||
assignment UI.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -31,6 +31,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"whiteboard": (2, 2),
|
||||
"tasks": (2, 2),
|
||||
"static": (1, 1),
|
||||
"text": (2, 1),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
@@ -511,6 +511,14 @@ def _migration_20(conn) -> None:
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
def _migration_21(conn) -> None:
|
||||
"""New widget type: a text widget shows user-authored rich text (see
|
||||
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
|
||||
-- another no-live-upstream type like migration 20's static image.
|
||||
Same brand-new-table create_all shape."""
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -532,6 +540,7 @@ MIGRATIONS = [
|
||||
(18, _migration_18),
|
||||
(19, _migration_19),
|
||||
(20, _migration_20),
|
||||
(21, _migration_21),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -553,6 +553,37 @@ class WhiteboardWidgetConfig(Base):
|
||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
class TextWidgetConfig(Base):
|
||||
"""One text widget's authored content + display settings -- another
|
||||
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||
instead of an uploaded image. content is never raw HTML: the
|
||||
dialog's contenteditable innerHTML is parsed server-side (see
|
||||
app/text_content.py, the sanitization boundary) into this plain
|
||||
run structure at save time, so render() (app/widgets/text.py) never
|
||||
re-parses/sanitizes HTML on every panel refresh, and the dialog never
|
||||
re-injects stored HTML via innerHTML when reopened.
|
||||
|
||||
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
|
||||
outer list is paragraphs (line breaks), inner list is styled runs
|
||||
within that paragraph. color/bg are "#rrggbb" or null (falls back to
|
||||
black text / no highlight). NULL (not just []) means never
|
||||
configured, matching StaticWidgetConfig.image's None-vs-empty
|
||||
convention for "not configured yet"."""
|
||||
|
||||
__tablename__ = "text_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Base point size for the whole block -- render() shrinks this down
|
||||
# (never up) to fit the widget's actual box; per-run font size isn't
|
||||
# supported, only the bold/italic/underline/color/bg style flags are
|
||||
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
|
||||
# layout in app/widgets/text.py to one size per render pass.
|
||||
font_size: Mapped[int] = mapped_column(Integer, default=28)
|
||||
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
||||
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
||||
|
||||
|
||||
class StaticWidgetConfig(Base):
|
||||
"""One static-image widget's uploaded content + display settings --
|
||||
unlike every other widget type, this one has no live upstream to
|
||||
@@ -583,6 +614,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"whiteboard": WhiteboardWidgetConfig,
|
||||
"tasks": TaskWidgetConfig,
|
||||
"static": StaticWidgetConfig,
|
||||
"text": TextWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DEFAULT_STATIC_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
hex_to_rgb,
|
||||
STATIC_DISPLAY_MODES,
|
||||
render_preview_png,
|
||||
)
|
||||
@@ -45,11 +46,14 @@ from ..models import (
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
)
|
||||
from ..text_content import has_text, parse_rich_text
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from ..widgets import text as text_widget
|
||||
from .common import (
|
||||
calendar_sources_for_widget,
|
||||
fetch_source_and_faces,
|
||||
@@ -69,6 +73,8 @@ router = APIRouter()
|
||||
|
||||
MIN_QUEUE_TARGET_LEN = 5
|
||||
MAX_QUEUE_TARGET_LEN = 5000
|
||||
MIN_TEXT_FONT_SIZE = 10
|
||||
MAX_TEXT_FONT_SIZE = 96
|
||||
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
||||
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
||||
|
||||
@@ -271,6 +277,11 @@ def api_widget_config_save(
|
||||
# tasks
|
||||
tasks_name: str | None = Form(None),
|
||||
tasks_show_completed: bool | None = Form(None),
|
||||
# text
|
||||
text_html: str | None = Form(None),
|
||||
text_font_size: int | None = Form(None),
|
||||
text_align: str | None = Form(None),
|
||||
text_background_color: str | None = Form(None),
|
||||
):
|
||||
"""Every field optional -- same partial-update, form-urlencoded
|
||||
convention as the old frame-level api_config_save, now scoped to one
|
||||
@@ -347,6 +358,21 @@ def api_widget_config_save(
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||
if display_mode is not None:
|
||||
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
||||
elif widget.widget_type == "text":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
||||
if text_html is not None:
|
||||
# The one save path that touches app/text_content.py --
|
||||
# see its module docstring for why parsing (not storing
|
||||
# raw HTML) is the actual sanitization boundary here.
|
||||
xcfg.content = parse_rich_text(text_html)
|
||||
if text_font_size is not None:
|
||||
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
|
||||
if text_align is not None:
|
||||
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
|
||||
if text_background_color is not None:
|
||||
xcfg.background_color = (
|
||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||
)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
@@ -852,6 +878,25 @@ def api_widget_preview_static(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Text: preview ----------------------------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
|
||||
def api_widget_preview_text(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The saved rich text run through the same word-wrap/shrink-to-fit
|
||||
layout and quantize pass a live device render would use -- same
|
||||
"reflects what's currently saved" convention as the other preview
|
||||
endpoints."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "text")
|
||||
xcfg = db.get(TextWidgetConfig, widget.id)
|
||||
if not has_text(xcfg.content):
|
||||
raise HTTPException(400, "No text authored on this widget yet")
|
||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Whiteboard: source/preview ------------------------------------------
|
||||
|
||||
class WhiteboardSourceRequest(BaseModel):
|
||||
|
||||
@@ -35,6 +35,7 @@ from ..models import (
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
User,
|
||||
UserFrame,
|
||||
WhiteboardWidgetConfig,
|
||||
@@ -245,6 +246,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
||||
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
||||
})
|
||||
|
||||
if widget.widget_type == "text":
|
||||
text_cfg = db.get(TextWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_text.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
||||
})
|
||||
|
||||
if widget.widget_type == "whiteboard":
|
||||
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||
viewer_has_webdav_creds = bool(
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||
static: 'Static image',
|
||||
static: 'Static image', text: 'Text',
|
||||
};
|
||||
|
||||
function showStatus(ok, message) {
|
||||
|
||||
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
|
||||
// icon was clicked).
|
||||
const DIALOG_INIT = {
|
||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||
tasks: closeTasksDialog, static: closeStaticDialog,
|
||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
||||
};
|
||||
|
||||
let openDialogWidgetType = null;
|
||||
|
||||
@@ -205,6 +205,12 @@ details.card .sub { margin-top: 8px; }
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[type="color"] {
|
||||
width: 44px;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
@@ -281,6 +287,49 @@ input:focus, select:focus {
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.richtext-btn {
|
||||
width: auto;
|
||||
min-width: 32px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.richtext-btn:hover { background: var(--surface); }
|
||||
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
|
||||
.richtext-color-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.richtext-color-label input[type="color"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.richtext-editor {
|
||||
margin-top: 8px;
|
||||
min-height: 90px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.calendar-user-list > li { margin-top: 14px; }
|
||||
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Text widget dialog: a small rich-text editor (bold/italic/underline,
|
||||
// text/highlight color), font size/alignment/background settings, and
|
||||
// the rendered preview. Not a page-load script -- frame_layout.js
|
||||
// fetches this widget's dialog HTML fragment, injects it into the
|
||||
// shared <dialog>, points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
|
||||
//
|
||||
// The editor is never seeded via innerHTML string interpolation --
|
||||
// #text-editor's data-content attribute (server-rendered from
|
||||
// app/text_content.py's already-sanitized run structure, not raw HTML)
|
||||
// is JSON-parsed and rebuilt with createElement/textContent below. The
|
||||
// same <div><span style="..."> shape this produces is exactly what
|
||||
// app/text_content.py's parser expects back on save, so round-tripping
|
||||
// (load -> edit -> save -> reload) is stable.
|
||||
|
||||
function loadTextPreview() {
|
||||
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function buildTextEditorContent(editor, paragraphs) {
|
||||
editor.textContent = '';
|
||||
if (!paragraphs || !paragraphs.length) return;
|
||||
paragraphs.forEach((para) => {
|
||||
const div = document.createElement('div');
|
||||
if (!para.length) {
|
||||
div.appendChild(document.createElement('br'));
|
||||
} else {
|
||||
para.forEach((run) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = run.text;
|
||||
if (run.bold) span.style.fontWeight = 'bold';
|
||||
if (run.italic) span.style.fontStyle = 'italic';
|
||||
if (run.underline) span.style.textDecoration = 'underline';
|
||||
if (run.color) span.style.color = run.color;
|
||||
if (run.bg) span.style.backgroundColor = run.bg;
|
||||
div.appendChild(span);
|
||||
});
|
||||
}
|
||||
editor.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
let _textSavedRange = null;
|
||||
let _textSelectionHandler = null;
|
||||
|
||||
function initTextDialog() {
|
||||
const editor = document.getElementById('text-editor');
|
||||
let initialContent = null;
|
||||
try {
|
||||
initialContent = JSON.parse(editor.dataset.content || 'null');
|
||||
} catch (e) { /* leave empty */ }
|
||||
buildTextEditorContent(editor, initialContent);
|
||||
|
||||
// Native <input type="color"> steals focus (and with it, the
|
||||
// editor's text selection) the moment it's interacted with -- track
|
||||
// the most recent in-editor selection continuously so a color pick
|
||||
// can be reapplied to the text the user actually had selected,
|
||||
// instead of applying to nothing.
|
||||
_textSelectionHandler = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
|
||||
_textSavedRange = sel.getRangeAt(0).cloneRange();
|
||||
}
|
||||
};
|
||||
document.addEventListener('selectionchange', _textSelectionHandler);
|
||||
|
||||
function restoreSelection() {
|
||||
if (!_textSavedRange) return;
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(_textSavedRange);
|
||||
}
|
||||
|
||||
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
|
||||
const btn = document.getElementById(id);
|
||||
// preventDefault on mousedown keeps focus (and the selection) in
|
||||
// the editor, so the click's execCommand has something to act on.
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
btn.addEventListener('click', () => {
|
||||
editor.focus();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('text-color').addEventListener('input', (e) => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('foreColor', false, e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('text-highlight').addEventListener('input', (e) => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('hiliteColor', false, e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
|
||||
document.getElementById('text-highlight-clear').addEventListener('click', () => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('hiliteColor', false, 'transparent');
|
||||
});
|
||||
|
||||
document.getElementById('text_font_size').addEventListener('input', (e) => {
|
||||
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
|
||||
});
|
||||
|
||||
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
text_html: editor.innerHTML,
|
||||
text_font_size: document.getElementById('text_font_size').value,
|
||||
text_align: document.getElementById('text_align').value,
|
||||
text_background_color: document.getElementById('text_background_color').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadTextPreview();
|
||||
} catch (e2) {
|
||||
showStatus(false, e2.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||
loadTextPreview();
|
||||
}
|
||||
|
||||
function closeTextDialog() {
|
||||
if (_textSelectionHandler) {
|
||||
document.removeEventListener('selectionchange', _textSelectionHandler);
|
||||
_textSelectionHandler = null;
|
||||
}
|
||||
_textSavedRange = null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<h2 class="dialog-title">Text widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Text</h2>
|
||||
<p class="sub">A short block of styled text -- select some and use the
|
||||
toolbar, or just type. Saved as plain text plus style flags, not raw
|
||||
HTML.</p>
|
||||
<div class="richtext-toolbar" id="text-toolbar">
|
||||
<button type="button" class="richtext-btn" id="text-bold" title="Bold" data-cmd="bold"><b>B</b></button>
|
||||
<button type="button" class="richtext-btn" id="text-italic" title="Italic" data-cmd="italic"><i>I</i></button>
|
||||
<button type="button" class="richtext-btn" id="text-underline" title="Underline" data-cmd="underline"><u>U</u></button>
|
||||
<span class="richtext-toolbar-sep"></span>
|
||||
<label class="richtext-color-label" title="Text color">A<input type="color" id="text-color" value="#000000"></label>
|
||||
<label class="richtext-color-label" title="Highlight color">▣<input type="color" id="text-highlight" value="#ffff00"></label>
|
||||
<button type="button" class="richtext-btn" id="text-highlight-clear" title="Remove highlight">×</button>
|
||||
</div>
|
||||
<div class="richtext-editor" id="text-editor" contenteditable="true"
|
||||
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="text-config-form">
|
||||
<label>Font size
|
||||
<input type="range" id="text_font_size" min="10" max="96" step="2"
|
||||
value="{{ text_cfg.font_size if text_cfg else 28 }}">
|
||||
<span class="slider-value" id="text_font_size_value">{{ text_cfg.font_size if text_cfg else 28 }}px</span>
|
||||
</label>
|
||||
<label>Alignment
|
||||
<select id="text_align">
|
||||
{% for value, label in [("left", "Left"), ("center", "Center"), ("right", "Right")] %}
|
||||
<option value="{{ value }}" {% if text_cfg and text_cfg.align == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Background color
|
||||
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="text-preview" alt="Text widget preview">
|
||||
<button type="button" class="secondary" id="text-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -60,5 +60,6 @@
|
||||
<script src="/static/widget_dialog_whiteboard.js"></script>
|
||||
<script src="/static/widget_dialog_tasks.js"></script>
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
|
||||
text.js's POSTed content_html) into a plain, storage-safe run structure
|
||||
-- list of paragraphs, each a list of {"text", "bold", "italic",
|
||||
"underline", "color", "bg"} runs -- for the text widget (see
|
||||
models.TextWidgetConfig, app/widgets/text.py).
|
||||
|
||||
This is the sanitization boundary the checklist's stored-XSS note
|
||||
(CLAUDE.md, another linked user could have set this) is about: raw HTML
|
||||
never round-trips back into any browser DOM. Only text content and a
|
||||
small fixed set of style flags survive parsing; every tag, attribute,
|
||||
and CSS property not explicitly recognized below is simply discarded --
|
||||
there's no allowlist-of-tags-to-keep-as-HTML step where something could
|
||||
slip through unescaped, because nothing is ever re-emitted as HTML at
|
||||
all. The dialog reconstructs its editor from this same run structure via
|
||||
safe DOM calls (createElement/textContent), never innerHTML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
# Generous ceilings, not exact UX limits -- just stop a direct API call
|
||||
# (bypassing the dialog's own textarea-ish size) from storing something
|
||||
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
|
||||
# bounds what's actually kept (a widget's on-panel region is a few
|
||||
# hundred pixels -- there is no legible use for more than a few thousand
|
||||
# characters of body text there).
|
||||
MAX_INPUT_CHARS = 200_000
|
||||
MAX_TOTAL_CHARS = 4_000
|
||||
|
||||
_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
|
||||
_BLOCK_TAGS = {"div", "p", "li"}
|
||||
_VOID_TAGS = {"br"}
|
||||
|
||||
_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
|
||||
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
|
||||
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
|
||||
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
|
||||
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}
|
||||
|
||||
|
||||
def _normalize_color(value: str) -> str | None:
|
||||
""""#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
|
||||
(a CSS named color, "transparent", garbage) -> None, i.e. dropped --
|
||||
this is the one place an arbitrary style-attribute string could try
|
||||
to smuggle something through, so it's a strict allowlist match, not
|
||||
a best-effort parse."""
|
||||
value = value.strip()
|
||||
m = _HEX6.match(value)
|
||||
if m:
|
||||
return "#" + m.group(1).lower()
|
||||
m = _HEX3.match(value)
|
||||
if m:
|
||||
return "#" + "".join(c * 2 for c in m.group(1)).lower()
|
||||
m = _RGB.match(value)
|
||||
if m:
|
||||
r, g, b = (max(0, min(255, int(x))) for x in m.groups())
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
return None
|
||||
|
||||
|
||||
class _RichTextParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.paragraphs: list[list[dict]] = [[]]
|
||||
self._style_stack: list[dict] = [_BASE_STYLE]
|
||||
self._at_line_start = True
|
||||
|
||||
def _break(self, tag: str) -> None:
|
||||
# Coalesces contenteditable's per-line block wrapping (Chrome
|
||||
# wraps every line in its own <div> even without a deliberate
|
||||
# blank line) down to one paragraph break per actual line gap,
|
||||
# while still letting an explicit <br> when already at a fresh
|
||||
# line start (Chrome's "<div><br></div>" idiom for a blank line,
|
||||
# or a genuine double Shift+Enter) add a real blank paragraph.
|
||||
if not self._at_line_start:
|
||||
self.paragraphs.append([])
|
||||
self._at_line_start = True
|
||||
elif tag == "br":
|
||||
self.paragraphs.append([])
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
|
||||
self._break(tag)
|
||||
if tag in _VOID_TAGS:
|
||||
return
|
||||
style = dict(self._style_stack[-1])
|
||||
attrs_dict = {k: v for k, v in attrs if v is not None}
|
||||
if tag in ("b", "strong"):
|
||||
style["bold"] = True
|
||||
elif tag in ("i", "em"):
|
||||
style["italic"] = True
|
||||
elif tag == "u":
|
||||
style["underline"] = True
|
||||
elif tag == "font":
|
||||
color = _normalize_color(attrs_dict.get("color", ""))
|
||||
if color:
|
||||
style["color"] = color
|
||||
elif tag == "span":
|
||||
for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
|
||||
prop = prop.strip().lower()
|
||||
val = val.strip()
|
||||
if prop == "color":
|
||||
color = _normalize_color(val)
|
||||
if color:
|
||||
style["color"] = color
|
||||
elif prop == "background-color":
|
||||
color = _normalize_color(val)
|
||||
if color:
|
||||
style["bg"] = color
|
||||
elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
|
||||
style["bold"] = True
|
||||
elif prop == "font-style" and val.lower() == "italic":
|
||||
style["italic"] = True
|
||||
elif prop == "text-decoration" and "underline" in val.lower():
|
||||
style["underline"] = True
|
||||
# Pushed for every non-void tag, including ones with no
|
||||
# recognized style effect (script/a/img/...) -- keeps push/pop
|
||||
# balanced against handle_endtag regardless of tag, without
|
||||
# needing to track which tags actually pushed something.
|
||||
self._style_stack.append(style)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _VOID_TAGS:
|
||||
return
|
||||
if len(self._style_stack) > 1:
|
||||
self._style_stack.pop()
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not data:
|
||||
return
|
||||
style = self._style_stack[-1]
|
||||
self.paragraphs[-1].append({"text": data, **style})
|
||||
if data.strip():
|
||||
self._at_line_start = False
|
||||
|
||||
|
||||
def parse_rich_text(html: str) -> list[list[dict]]:
|
||||
"""The sanitization entry point -- see module docstring. Always
|
||||
returns a valid (possibly all-empty) paragraphs structure, never
|
||||
raises for malformed markup (html.parser tolerates unclosed/
|
||||
mismatched tags; handle_endtag's length guard tolerates an
|
||||
over-popped stack)."""
|
||||
parser = _RichTextParser()
|
||||
parser.feed(html[:MAX_INPUT_CHARS])
|
||||
parser.close()
|
||||
paragraphs = parser.paragraphs
|
||||
|
||||
total = 0
|
||||
truncated: list[list[dict]] = []
|
||||
for para in paragraphs:
|
||||
new_para: list[dict] = []
|
||||
for run in para:
|
||||
remaining = MAX_TOTAL_CHARS - total
|
||||
if remaining <= 0:
|
||||
break
|
||||
text = run["text"][:remaining]
|
||||
total += len(text)
|
||||
new_para.append({**run, "text": text})
|
||||
truncated.append(new_para)
|
||||
if total >= MAX_TOTAL_CHARS:
|
||||
break
|
||||
return truncated
|
||||
|
||||
|
||||
def has_text(paragraphs: list[list[dict]] | None) -> bool:
|
||||
if not paragraphs:
|
||||
return False
|
||||
return any(run["text"].strip() for para in paragraphs for run in para)
|
||||
@@ -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()
|
||||
@@ -76,6 +76,7 @@ def test_expected_columns_exist_on_current_schema():
|
||||
assert "calendar_week_start_offset" in frame_columns
|
||||
assert "name" in task_widget_columns # migration 19
|
||||
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
||||
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""app.text_content.parse_rich_text -- pure parsing logic, no HTTP, no
|
||||
DB. This is the sanitization boundary for the text widget's dialog
|
||||
(widget_dialog_text.js posts contenteditable innerHTML here, see
|
||||
routers/api_widgets.py's api_widget_config_save "text" branch); the
|
||||
main thing under test is that only recognized style flags survive and
|
||||
everything else (unknown tags/attributes, unparseable colors, excess
|
||||
length) is silently dropped rather than round-tripped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.text_content import MAX_TOTAL_CHARS, has_text, parse_rich_text
|
||||
|
||||
|
||||
def test_plain_text_is_one_paragraph_one_run():
|
||||
paragraphs = parse_rich_text("Hello world")
|
||||
assert paragraphs == [[{"text": "Hello world", "bold": False, "italic": False,
|
||||
"underline": False, "color": None, "bg": None}]]
|
||||
|
||||
|
||||
def test_bold_italic_underline_tags():
|
||||
paragraphs = parse_rich_text("<b>bold</b> <i>italic</i> <u>under</u>")
|
||||
runs = paragraphs[0]
|
||||
assert runs[0]["bold"] is True and runs[0]["text"] == "bold"
|
||||
assert runs[2]["italic"] is True and runs[2]["text"] == "italic"
|
||||
assert runs[4]["underline"] is True and runs[4]["text"] == "under"
|
||||
|
||||
|
||||
def test_strong_and_em_are_treated_like_b_and_i():
|
||||
paragraphs = parse_rich_text("<strong>bold</strong><em>italic</em>")
|
||||
runs = paragraphs[0]
|
||||
assert runs[0]["bold"] is True
|
||||
assert runs[1]["italic"] is True
|
||||
|
||||
|
||||
def test_span_style_color_and_background():
|
||||
html = '<span style="color: rgb(207, 0, 15); background-color: #ffdb00;">hi</span>'
|
||||
run = parse_rich_text(html)[0][0]
|
||||
assert run["color"] == "#cf000f"
|
||||
assert run["bg"] == "#ffdb00"
|
||||
|
||||
|
||||
def test_span_style_font_weight_and_style_and_decoration():
|
||||
html = '<span style="font-weight: bold; font-style: italic; text-decoration: underline;">x</span>'
|
||||
run = parse_rich_text(html)[0][0]
|
||||
assert run["bold"] is True
|
||||
assert run["italic"] is True
|
||||
assert run["underline"] is True
|
||||
|
||||
|
||||
def test_font_tag_color_attribute():
|
||||
run = parse_rich_text('<font color="#00ff00">green</font>')[0][0]
|
||||
assert run["color"] == "#00ff00"
|
||||
|
||||
|
||||
def test_short_hex_color_expands():
|
||||
run = parse_rich_text('<span style="color: #f00;">red</span>')[0][0]
|
||||
assert run["color"] == "#ff0000"
|
||||
|
||||
|
||||
def test_unparseable_color_is_dropped():
|
||||
run = parse_rich_text('<span style="color: papayawhip;">x</span>')[0][0]
|
||||
assert run["color"] is None
|
||||
|
||||
|
||||
def test_nested_styles_combine():
|
||||
run = parse_rich_text("<b><i>both</i></b>")[0][0]
|
||||
assert run["bold"] is True
|
||||
assert run["italic"] is True
|
||||
|
||||
|
||||
def test_style_does_not_leak_past_closing_tag():
|
||||
paragraphs = parse_rich_text("<b>bold</b>plain")
|
||||
runs = paragraphs[0]
|
||||
assert runs[0]["bold"] is True
|
||||
assert runs[1]["bold"] is False
|
||||
|
||||
|
||||
def test_div_per_line_becomes_separate_paragraphs():
|
||||
paragraphs = parse_rich_text("<div>line one</div><div>line two</div>")
|
||||
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
|
||||
|
||||
|
||||
def test_shift_enter_br_within_a_div_also_breaks_paragraphs():
|
||||
paragraphs = parse_rich_text("<div>line one<br>line two</div>")
|
||||
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
|
||||
|
||||
|
||||
def test_blank_line_idiom_produces_one_empty_paragraph():
|
||||
html = "<div>A</div><div><br></div><div>B</div>"
|
||||
paragraphs = parse_rich_text(html)
|
||||
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, "B"]
|
||||
|
||||
|
||||
def test_double_blank_line_produces_two_empty_paragraphs():
|
||||
html = "<div>A</div><div><br></div><div><br></div><div>B</div>"
|
||||
paragraphs = parse_rich_text(html)
|
||||
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, None, "B"]
|
||||
|
||||
|
||||
def test_mid_word_style_change_does_not_insert_a_space():
|
||||
""""wor" bolded, "ld" not -- must still read as one word "world" when
|
||||
rendered (see app/widgets/text.py's word-grouping), not "wor ld"."""
|
||||
paragraphs = parse_rich_text("<b>wor</b>ld")
|
||||
runs = paragraphs[0]
|
||||
assert [r["text"] for r in runs] == ["wor", "ld"]
|
||||
|
||||
|
||||
def test_unrecognized_tags_are_dropped_but_their_text_survives_as_plain():
|
||||
"""A <script> (or any tag outside the recognized set) never executes
|
||||
or persists as a tag -- its text content just becomes an ordinary
|
||||
unstyled run, exactly like any other stray text."""
|
||||
paragraphs = parse_rich_text('<script>alert(1)</script>hello')
|
||||
runs = paragraphs[0]
|
||||
assert any(r["text"] == "alert(1)" and not r["bold"] for r in runs)
|
||||
assert any(r["text"] == "hello" for r in runs)
|
||||
|
||||
|
||||
def test_style_attribute_cannot_smuggle_unrecognized_css():
|
||||
"""Only color/background-color/font-weight/font-style/text-decoration
|
||||
are ever read from a style attribute -- anything else (a CSS
|
||||
injection attempt via e.g. a bogus property) is just ignored."""
|
||||
html = '<span style="position: fixed; top: 0; color: #123456;">x</span>'
|
||||
run = parse_rich_text(html)[0][0]
|
||||
assert run["color"] == "#123456"
|
||||
# No other keys were introduced by the extra property.
|
||||
assert set(run.keys()) == {"text", "bold", "italic", "underline", "color", "bg"}
|
||||
|
||||
|
||||
def test_content_is_truncated_to_max_total_chars():
|
||||
html = "a" * (MAX_TOTAL_CHARS + 500)
|
||||
paragraphs = parse_rich_text(html)
|
||||
total = sum(len(r["text"]) for p in paragraphs for r in p)
|
||||
assert total == MAX_TOTAL_CHARS
|
||||
|
||||
|
||||
def test_malformed_html_does_not_raise():
|
||||
parse_rich_text("<b><i>unclosed tags <div>and a stray </b>")
|
||||
|
||||
|
||||
def test_has_text_false_for_none_and_blank():
|
||||
assert has_text(None) is False
|
||||
assert has_text([]) is False
|
||||
assert has_text([[]]) is False
|
||||
assert has_text([[{"text": " ", "bold": False, "italic": False,
|
||||
"underline": False, "color": None, "bg": None}]]) is False
|
||||
|
||||
|
||||
def test_has_text_true_for_real_content():
|
||||
assert has_text(parse_rich_text("hi")) is True
|
||||
@@ -19,6 +19,7 @@ from app.models import (
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
@@ -67,6 +68,18 @@ def _add_static_widget(db_session) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def _add_text_widget(db_session) -> Widget:
|
||||
import time
|
||||
|
||||
widget = Widget(frame_id=1, widget_type="text", x=0, y=0, w=2, h=1,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(TextWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
@@ -184,6 +197,49 @@ def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_sess
|
||||
assert cfg.display_mode == "crop_fill"
|
||||
|
||||
|
||||
def test_config_save_updates_a_text_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_text_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={
|
||||
"text_html": '<div>Hello <b>world</b></div>',
|
||||
"text_font_size": "40",
|
||||
"text_align": "center",
|
||||
"text_background_color": "#ffdb00",
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(TextWidgetConfig, widget.id)
|
||||
assert cfg.content == [[
|
||||
{"text": "Hello ", "bold": False, "italic": False, "underline": False, "color": None, "bg": None},
|
||||
{"text": "world", "bold": True, "italic": False, "underline": False, "color": None, "bg": None},
|
||||
]]
|
||||
assert cfg.font_size == 40
|
||||
assert cfg.align == "center"
|
||||
assert cfg.background_color == "#ffdb00"
|
||||
|
||||
|
||||
def test_config_save_clamps_text_font_size_and_rejects_bad_align_and_color(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_text_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"text_font_size": "500", "text_align": "diagonal", "text_background_color": "not-a-color"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(TextWidgetConfig, widget.id)
|
||||
assert cfg.font_size == 96 # clamped to MAX_TEXT_FONT_SIZE
|
||||
assert cfg.align == "left" # fell back to the default
|
||||
assert cfg.background_color == "#ffffff" # fell back to the default
|
||||
|
||||
|
||||
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
||||
"""Fields not present in the POST are left untouched -- the whole
|
||||
point of the partial-update convention (each dialog's own form only
|
||||
@@ -330,3 +386,33 @@ def test_preview_static_renders_after_upload(client, db_session):
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
# --- text: preview -----------------------------------------------------
|
||||
|
||||
def test_preview_text_400s_before_anything_is_authored(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_text_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_text_renders_after_saving_content(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_text_widget(db_session)
|
||||
client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"text_html": "<div>Hello world</div>"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""app.widgets.text -- unit-level, no HTTP: constructs Widget/
|
||||
TextWidgetConfig rows directly with already-parsed run structures (the
|
||||
HTML-parsing step is covered separately in test_text_content.py; the
|
||||
HTTP-level config-save/preview endpoints in
|
||||
test_widget_config_and_queue_endpoints.py). These tests only exercise
|
||||
render()'s own word-wrap/shrink-to-fit/style layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app import widgets
|
||||
from app.models import Frame, TextWidgetConfig, Widget
|
||||
|
||||
|
||||
def _run(text, **overrides) -> dict:
|
||||
run = {"text": text, "bold": False, "italic": False, "underline": False, "color": None, "bg": None}
|
||||
run.update(overrides)
|
||||
return run
|
||||
|
||||
|
||||
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = Widget(frame_id=frame.id, widget_type="text", x=0, y=0, w=2, h=1,
|
||||
sort_order=0, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(TextWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||
db_session.commit()
|
||||
return frame, widget
|
||||
|
||||
|
||||
def test_render_shows_a_placeholder_when_never_configured(db_session):
|
||||
frame, widget = _make_widget(db_session)
|
||||
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_shows_a_placeholder_for_whitespace_only_content(db_session):
|
||||
frame, widget = _make_widget(db_session, content=[[_run(" ")]])
|
||||
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
|
||||
|
||||
def test_render_draws_configured_text(db_session):
|
||||
frame, widget = _make_widget(db_session, content=[[_run("Hello world")]])
|
||||
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
# Not just a blank/placeholder canvas -- some non-background pixel exists.
|
||||
assert img.getcolors(maxcolors=1) is None or img.getcolors()[0][0] != 300 * 200
|
||||
|
||||
|
||||
def test_render_respects_background_color(db_session):
|
||||
frame, widget = _make_widget(db_session, content=[[_run("hi")]], background_color="#ff0000")
|
||||
img = widgets.text.render(db_session, frame, widget, 50, 40)
|
||||
assert img.getpixel((0, 0)) == (255, 0, 0)
|
||||
|
||||
|
||||
def test_render_shrinks_font_to_fit_a_tiny_box(db_session):
|
||||
long_text = " ".join(["word"] * 40)
|
||||
frame, widget = _make_widget(db_session, content=[[_run(long_text)]], font_size=96)
|
||||
# grid.MIN_FOOTPRINT["text"] is (2, 1) cells -- on an 8x5 grid against
|
||||
# a full 800x480 panel that's a 200x96 box, the smallest a text
|
||||
# widget can actually be placed at.
|
||||
img = widgets.text.render(db_session, frame, widget, 200, 96)
|
||||
assert img.size == (200, 96)
|
||||
|
||||
|
||||
def test_render_wraps_across_multiple_paragraphs(db_session):
|
||||
content = [[_run("First paragraph with several words to wrap.")],
|
||||
[_run("Second paragraph, also with text.")]]
|
||||
frame, widget = _make_widget(db_session, content=content)
|
||||
img = widgets.text.render(db_session, frame, widget, 250, 150)
|
||||
assert img.size == (250, 150)
|
||||
|
||||
|
||||
def test_render_applies_bold_italic_underline_color_and_highlight(db_session):
|
||||
content = [[
|
||||
_run("bold", bold=True),
|
||||
_run(" "),
|
||||
_run("italic", italic=True),
|
||||
_run(" "),
|
||||
_run("underline", underline=True),
|
||||
_run(" "),
|
||||
_run("colored", color="#cf000f"),
|
||||
_run(" "),
|
||||
_run("highlighted", bg="#ffdb00"),
|
||||
]]
|
||||
frame, widget = _make_widget(db_session, content=content)
|
||||
img = widgets.text.render(db_session, frame, widget, 400, 150)
|
||||
assert img.size == (400, 150)
|
||||
|
||||
|
||||
def test_render_respects_alignment(db_session):
|
||||
for align in ("left", "center", "right"):
|
||||
frame, widget = _make_widget(db_session, content=[[_run("hi")]], align=align)
|
||||
img = widgets.text.render(db_session, frame, widget, 200, 100)
|
||||
assert img.size == (200, 100)
|
||||
|
||||
|
||||
def test_no_button_actions():
|
||||
"""Fixed authored text -- nothing to advance/back/check."""
|
||||
assert widgets.text.ACTIONS == {}
|
||||
assert widgets.text.ACTION_LABELS == {}
|
||||
Reference in New Issue
Block a user