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.
150 lines
5.6 KiB
Python
150 lines
5.6 KiB
Python
"""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
|