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:
@@ -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