Add font family choice to the text widget, move toolbar below the editor
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:
+75
-24
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user