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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user