Six more vendored families alongside the existing Noto Sans (Inter, Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/ serif/slab/mono variety), all OFL-licensed with their own per-family license file in app/fonts/ since each has a different copyright holder. Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only families (Inter and Source Sans's current Google Fonts releases, plus Playfair Display/Lora/Merriweather) were skipped in favor of static builds from their own upstream repos, keeping every family's loading code uniform with what was already there. Considered but deliberately left out: Georgia -- a proprietary Microsoft core font, not freely redistributable, unlike everything else vendored here. Also moves the bold/italic/underline/color toolbar below the contenteditable box per request, and reorders the dialog's Settings card to a more natural family-then-size order. Fixes a latent migration bug this surfaced: migration 20 (static image widget) used Base.metadata.create_all, which creates every table declared in Base.metadata that's missing, not just its own new one -- harmless when nothing else pending, but once TextWidgetConfig existed it would silently pre-create text_widget_configs (in whatever shape models.py currently declares) before migration 21 got a turn, so migration 21's own CREATE TABLE (or a later ALTER TABLE adding font_family) would collide with a table create_all had already leaked into existence. Both migrations 20 and 21 now use raw, frozen CREATE TABLE SQL instead, matching migration 17's existing precedent for exactly this reason.
124 lines
4.9 KiB
Python
124 lines
4.9 KiB
Python
"""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_render_supports_every_font_family(db_session):
|
|
for family in widgets.text.FONT_FAMILIES:
|
|
frame, widget = _make_widget(db_session, content=[[_run("The quick brown fox")]], font_family=family)
|
|
img = widgets.text.render(db_session, frame, widget, 250, 100)
|
|
assert img.size == (250, 100)
|
|
|
|
|
|
def test_render_falls_back_to_default_family_for_an_unrecognized_value(db_session):
|
|
"""A stale/tampered font_family value (e.g. a family removed in a
|
|
later release) never crashes render() -- falls back to the default
|
|
the same way an unrecognized align/display_mode value does
|
|
elsewhere in this codebase."""
|
|
frame, widget = _make_widget(db_session, content=[[_run("hi")]], font_family="does-not-exist")
|
|
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 == {}
|