Add font family choice to the text widget, move toolbar below the editor
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 51s

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.
This commit is contained in:
Thomas Faour
2026-07-25 16:05:22 +00:00
parent 3735c5bfa7
commit edbd90745b
39 changed files with 659 additions and 36 deletions
+75 -24
View File
@@ -6,10 +6,13 @@ 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.
Bold/italic use real vendored font weights (app/fonts/*.ttf, OFL-
licensed like the emoji fonts already there) across a small curated set
of families (FONT_FAMILIES) rather than every other widget's single
ImageFont.load_default() -- the one widget type where that distinction
is the whole point. Font family is a whole-widget setting like
font_size/align, not per-run -- only bold/italic/underline/color/bg
vary run-to-run (see app/text_content.py).
No button actions -- there's nothing to advance/back/check for a fixed
block of authored text."""
@@ -38,18 +41,63 @@ DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
# A small curated set, not an open-ended picker -- each entry needs a
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
# only ship as a variable font (Playfair Display, Lora, Merriweather,
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
# of static builds from their own upstream repos where one exists (see
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
# they're all OFL, same as the Noto fonts already vendored here, but
# each has a different copyright holder so gets its own license file
# rather than sharing app/fonts/OFL.txt).
DEFAULT_FONT_FAMILY = "sans"
FONT_FAMILIES: dict[str, str] = {
"sans": "Sans-serif (Noto Sans)",
"inter": "Inter",
"source_sans": "Source Sans",
"serif": "Serif (Noto Serif)",
"elegant": "Elegant serif (Crimson Text)",
"slab": "Slab serif (Arvo)",
"mono": "Monospace (IBM Plex Mono)",
}
_FONT_FILES = {
(False, False): "NotoSans-Regular.ttf",
(True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf",
(True, True): "NotoSans-BoldItalic.ttf",
"sans": {
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
},
"inter": {
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
},
"source_sans": {
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
},
"serif": {
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
},
"elegant": {
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
},
"slab": {
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
},
"mono": {
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-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)
@lru_cache(maxsize=256)
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
@@ -75,11 +123,12 @@ def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
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 _group_width(draw: ImageDraw.ImageDraw, group: list[dict], family: str, size: int) -> float:
return sum(draw.textlength(tok["text"], font=_font(family, tok["bold"], tok["italic"], size))
for tok in group)
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: int,
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], family: str, 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
@@ -88,7 +137,7 @@ def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: i
current: list[list[dict]] = []
current_w = 0.0
for group in groups:
gw = _group_width(draw, group, size)
gw = _group_width(draw, group, family, size)
add_w = gw + (space_width if current else 0)
if current and current_w + add_w > max_width:
lines.append(current)
@@ -102,7 +151,7 @@ def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: i
return lines
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: int,
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], family: str, 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 --
@@ -112,10 +161,11 @@ def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: in
size = max(MIN_FONT_SIZE, start_size)
lines: list[list[list[dict]]] = []
while True:
space_width = draw.textlength(" ", font=_font(False, False, size))
space_width = draw.textlength(" ", font=_font(family, False, False, size))
lines = []
for paragraph in paragraphs:
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), size, max_width, space_width))
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), family, 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:
@@ -124,8 +174,8 @@ def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: in
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)
family: str, size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
line_width = sum(_group_width(draw, g, family, 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":
@@ -135,7 +185,7 @@ def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict
underline_h = max(1, size // 16)
for gi, group in enumerate(line):
for tok in group:
font = _font(tok["bold"], tok["italic"], size)
font = _font(family, tok["bold"], tok["italic"], size)
w = draw.textlength(tok["text"], font=font)
if tok["bg"]:
bg_rgb = hex_to_rgb(tok["bg"])
@@ -156,11 +206,12 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
img = Image.new("RGB", (target_w, target_h), bg)
draw = ImageDraw.Draw(img)
family = cfg.font_family if cfg.font_family in FONT_FAMILIES else DEFAULT_FONT_FAMILY
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)
size, lines = _fit(draw, cfg.content or [], family, cfg.font_size, max_width, max_height)
line_h = round(size * LINE_HEIGHT_FACTOR)
space_width = draw.textlength(" ", font=_font(False, False, size))
space_width = draw.textlength(" ", font=_font(family, False, False, size))
total_h = len(lines) * line_h
y = MARGIN + max(0, (max_height - total_h) // 2)
@@ -168,7 +219,7 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
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)
_draw_line(img, draw, line, y, family, size, line_h, max_width, align, space_width)
y += line_h
return img