Files
DumiFlex 85152b3ec7 feat(label-grid): optional wires, one label for all, spacing and token tooltips (#178)
Optional label_text, title, row_headers and column_headers inputs beat the panel's fields. A single one-line label covers every image of every item. The panel has bottom spacing above the preview, and the label tokens explain themselves on hover.
2026-10-29 04:11:48 +01:00

769 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pure logic behind ``WP_LabelGrid`` — draw labels on images and lay them out
in a labelled grid.
Shapes. The node is ``is_input_list``, so ``images`` arrives as a list of
items and each item is an IMAGE batch: one image, a batch, a Context Loop's
N frames, or N frames of batches. Here every image is a PIL image and an item
is a list of them; the node converts tensors on the way in and out.
Labels. The optional ``labels`` input is a STRING list aligned with the items
(the Image Filter's ``labels`` output, or any text). Each entry splits on
newlines for the images of its batch; one line broadcasts to every image in
the item, missing lines and missing entries mean no label. A label may hold
parts joined by ``" · "`` (the Image Filter writes ``#N · option · option · K``),
and the grid can lift a part into the row or column headers, which also
arranges the cells by those values (an XY plot from a sweep).
Kept free of torch and ComfyUI imports so it is tested with plain pytest.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from typing import Any
from PIL import Image, ImageColor, ImageDraw, ImageFont
PART_SEP = " · "
POSITIONS = ("top", "bottom", "left", "right", "over_top", "over_bottom", "none")
ALIGNS = ("center", "left", "right")
#: auto = fit the run's shape (see ``auto_arrange``), rows = one row per input
#: item (its batch across), columns = turned around, fill = every image in
#: turn, ``columns`` per row (0 = near square), strip = one row, stack = one
#: column.
ARRANGE = ("auto", "rows", "columns", "fill", "strip", "stack")
#: ``auto`` lays a list of single images out in one row up to this many.
STRIP_MAX = 6
#: ``none`` / ``typed`` / ``part:N`` (N from 1, ``part:-1`` = the last part).
HEADER_FROM = re.compile(r"^(none|typed|part:-?[1-9]\d?)$")
DEFAULT_FONT = "default"
#: Lines a cell label may wrap to before it is cut with an ellipsis.
MAX_LABEL_LINES = 3
#: Lines a header may wrap to.
MAX_HEADER_LINES = 4
#: Hard cap on the grid's longest side; bigger grids are scaled down to it.
MAX_GRID_SIDE = 16_384
_TOKEN = re.compile(r"\{(label|frame|image|index|count)\}")
# ── labels ────────────────────────────────────────────────────────────────
def item_labels(labels: list[Any] | None, batch_sizes: list[int]) -> list[list[str]]:
"""One label per image: ``out[item][image]``.
``labels[item]`` splits on newlines for the item's batch. One line covers
every image of the item; with fewer lines than images the rest get none.
A single entry for several items whose lines match the total image count
is spread over all of them (typed text, one line per image), and a single
one-line entry labels every image of every item.
"""
out: list[list[str]] = []
if labels is not None and len(labels) == 1 and len(batch_sizes) > 1:
# One text for several items (a typed list, one line per image): when
# its lines match the image count, spread them over every image.
only = labels[0] if isinstance(labels[0], str) else ""
lines = [ln.strip() for ln in only.splitlines()]
if len(lines) == 1 and lines[0]:
return [[lines[0]] * size for size in batch_sizes]
if len(lines) == sum(batch_sizes):
for size in batch_sizes:
out.append(lines[:size])
lines = lines[size:]
return out
for i, size in enumerate(batch_sizes):
raw = labels[i] if labels is not None and i < len(labels) else None
text = raw if isinstance(raw, str) else ("" if raw is None else str(raw))
lines = [ln.strip() for ln in text.splitlines()] if text.strip() else []
if len(lines) == 1:
out.append([lines[0]] * size)
else:
out.append([lines[j] if j < len(lines) else "" for j in range(size)])
return out
def apply_template(
template: str, label: str, frame: int, image: int, index: int, count: int
) -> str:
"""Fill a label template. ``{label}`` is the incoming label; ``{frame}`` and
``{image}`` count from 1 inside the input, ``{index}`` from 1 across all
images, ``{count}`` is the number of images. An empty template means the
incoming label as it is."""
if not template.strip():
return label
values = {
"label": label,
"frame": str(frame + 1),
"image": str(image + 1),
"index": str(index + 1),
"count": str(count),
}
text = _TOKEN.sub(lambda m: values[m.group(1)], template)
return text.replace("\\n", "\n").strip()
def split_parts(label: str) -> list[str]:
return [p.strip() for p in label.split(PART_SEP)] if label else []
def part(label: str, index: int) -> str:
"""A label's part, counted from 1; negative counts from the end. ``""``
when the label has no such part."""
parts = split_parts(label)
if index > 0 and index <= len(parts):
return parts[index - 1]
if index < 0 and -index <= len(parts):
return parts[index]
return ""
def drop_parts(label: str, indexes: list[int]) -> str:
"""The label without the parts lifted into headers."""
parts = split_parts(label)
n = len(parts)
gone = {(i - 1) if i > 0 else (n + i) for i in indexes if i != 0}
return PART_SEP.join(p for k, p in enumerate(parts) if k not in gone)
def typed_headers(text: str) -> list[str]:
"""Headers typed into a widget: one per line (``\\n`` inside a line breaks it)."""
return [ln.strip().replace("\\n", "\n") for ln in text.splitlines()] if text.strip() else []
# ── config (the node's JSON widget) ───────────────────────────────────────
DEFAULT_CONFIG: dict[str, Any] = {
"label": "{label}",
"position": "top",
"align": "center",
"font": "default",
"font_size": 0,
"text_color": "#ffffff",
"label_color": "#000000",
"grid": True,
"arrange": "auto",
"columns": 0,
"rows_from": "none",
"row_headers": "",
"columns_from": "none",
"column_headers": "",
"title": "",
"gap": 8,
"margin": 0,
"background": "#ffffff",
"header_color": "#000000",
"cell_width": 0,
}
_INT_LIMITS = {"font_size": 1024, "columns": 256, "gap": 512, "margin": 1024, "cell_width": 8192}
_TEXT_KEYS = (
"label",
"font",
"text_color",
"label_color",
"row_headers",
"column_headers",
"title",
"background",
"header_color",
)
def parse_config(raw: Any) -> dict[str, Any]:
"""Decode the node's JSON widget, falling back per key: unknown keys are
ignored and a bad value takes its default, so an older or hand-edited
workflow still runs."""
out = dict(DEFAULT_CONFIG)
if isinstance(raw, str):
try:
raw = json.loads(raw) if raw.strip() else {}
except ValueError:
raw = {}
if not isinstance(raw, dict):
return out
for key, allowed in (("position", POSITIONS), ("align", ALIGNS), ("arrange", ARRANGE)):
if raw.get(key) in allowed:
out[key] = raw[key]
for key in ("rows_from", "columns_from"):
value = raw.get(key)
if isinstance(value, str) and HEADER_FROM.match(value) and value not in ("part:0",):
out[key] = value
for key, top in _INT_LIMITS.items():
value = raw.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool):
out[key] = max(0, min(top, int(value)))
for key in _TEXT_KEYS:
value = raw.get(key)
if isinstance(value, str):
out[key] = value
if isinstance(raw.get("grid"), bool):
out["grid"] = raw["grid"]
return out
def header_part(source: str) -> int:
"""The label part a ``part:N`` header source names; 0 for none / typed."""
if source.startswith("part:"):
try:
return int(source[5:])
except ValueError:
return 0
return 0
def part_examples(labels: list[str], parts: int = 6, per_part: int = 4) -> list[dict[str, Any]]:
"""Each label part's distinct values in first-seen order (a few of them)
and how many there are, so the widget can show what "part 2" holds and
pick the parts an XY plot should use."""
out: list[dict[str, Any]] = []
for k in range(1, parts + 1):
seen: list[str] = []
for label in labels:
value = part(label, k)
if value and value not in seen:
seen.append(value)
if not seen:
break
out.append({"values": seen[:per_part], "count": len(seen)})
return out
def auto_arrange(batch_sizes: list[int]) -> str:
"""What ``arrange = auto`` does with this run: one image or one batch
fills a near-square sheet, a few single images sit in one row, more of
them fill a sheet, and a list of batches gets one row per item."""
sizes = [s for s in batch_sizes if s > 0]
if len(sizes) <= 1:
return "fill"
if all(s == 1 for s in sizes):
return "strip" if len(sizes) <= STRIP_MAX else "fill"
return "rows"
# ── fonts + colours ───────────────────────────────────────────────────────
def font_dirs() -> list[str]:
"""Folders searched for fonts: the extension's ``fonts/`` then the OS's."""
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
dirs = [os.path.join(here, "fonts")]
windir = os.environ.get("WINDIR")
if windir:
dirs.append(os.path.join(windir, "Fonts"))
local = os.environ.get("LOCALAPPDATA")
if local:
dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
dirs += [
"/Library/Fonts",
"/System/Library/Fonts",
os.path.expanduser("~/Library/Fonts"),
"/usr/share/fonts",
"/usr/local/share/fonts",
os.path.expanduser("~/.fonts"),
]
return dirs
def list_fonts() -> list[str]:
"""``default`` plus every ``.ttf`` / ``.otf`` / ``.ttc`` found, by file name."""
seen: dict[str, str] = {}
for d in font_dirs():
if not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
for name in files:
if name.lower().endswith((".ttf", ".otf", ".ttc")) and name not in seen:
seen[name] = os.path.join(root, name)
return [DEFAULT_FONT, *sorted(seen, key=str.lower)]
def _find_font(name: str) -> str | None:
for d in font_dirs():
if not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
if name in files:
return os.path.join(root, name)
return None
_DEFAULT_CANDIDATES = (
"segoeui.ttf",
"arial.ttf",
"DejaVuSans.ttf",
"Arial.ttf",
"NotoSans-Regular.ttf",
"LiberationSans-Regular.ttf",
)
_FONT_CACHE: dict[tuple[str, int], Any] = {}
def load_font(name: str, size: int) -> Any:
"""A font at ``size`` px. Unknown names (a workflow from another machine)
fall back to Pillow's built-in font."""
key = (name, size)
if key in _FONT_CACHE:
return _FONT_CACHE[key]
font: Any = None
if name and name != DEFAULT_FONT:
path = _find_font(name)
if path:
try:
font = ImageFont.truetype(path, size)
except OSError:
font = None
if font is None:
# "default": a common Unicode font when the system has one, so labels
# like "×" or accents render; Pillow's built-in Latin font otherwise.
for fallback in _DEFAULT_CANDIDATES:
path = _find_font(fallback)
if path:
try:
font = ImageFont.truetype(path, size)
break
except OSError:
continue
if font is None:
try:
font = ImageFont.load_default(size)
except TypeError: # Pillow < 10.1: one bitmap size only
font = ImageFont.load_default()
_FONT_CACHE[key] = font
return font
def parse_color(value: str, fallback: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
"""``#rgb`` / ``#rrggbb`` / ``#rrggbbaa`` / a CSS name; ``""`` or ``none``
is fully transparent."""
text = (value or "").strip()
if text.lower() in ("", "none", "transparent"):
return (0, 0, 0, 0)
try:
rgba = ImageColor.getcolor(text, "RGBA")
except ValueError:
return fallback
if isinstance(rgba, int):
return (rgba, rgba, rgba, 255)
return (rgba[0], rgba[1], rgba[2], rgba[3] if len(rgba) > 3 else 255)
# ── text layout ───────────────────────────────────────────────────────────
def _text_width(font: Any, text: str) -> float:
return float(font.getlength(text))
def wrap(text: str, font: Any, width: float, max_lines: int) -> list[str]:
"""Greedy word wrap to ``width`` px; explicit newlines are kept. A word
wider than the line is broken by characters. Past ``max_lines`` the last
line ends with an ellipsis."""
lines: list[str] = []
for para in text.split("\n"):
words = para.split(" ")
line = ""
for word in words:
candidate = f"{line} {word}" if line else word
fits = _text_width(font, candidate) <= width
if fits or not line and _text_width(font, word) <= width:
line = candidate
continue
if line:
lines.append(line)
line = ""
while _text_width(font, word) > width and len(word) > 1:
cut = len(word)
while cut > 1 and _text_width(font, word[:cut]) > width:
cut -= 1
lines.append(word[:cut])
word = word[cut:]
line = word
lines.append(line)
if len(lines) > max_lines:
lines = lines[:max_lines]
last = lines[-1]
while last and _text_width(font, last + "…") > width:
last = last[:-1]
lines[-1] = last.rstrip() + "…"
return lines
def line_height(font: Any) -> int:
ascent, descent = font.getmetrics()
return int(round((ascent + descent) * 1.15))
def text_block_height(lines: list[str], font: Any) -> int:
return line_height(font) * len(lines) if lines else 0
def draw_lines(
draw: ImageDraw.ImageDraw,
lines: list[str],
font: Any,
box: tuple[int, int, int, int],
color: tuple[int, int, int, int],
align: str = "center",
valign: str = "center",
) -> None:
"""Draw wrapped lines inside ``box`` (x0, y0, x1, y1)."""
x0, y0, x1, y1 = box
lh = line_height(font)
total = lh * len(lines)
if valign == "top":
y = y0
elif valign == "bottom":
y = y1 - total
else:
y = y0 + (y1 - y0 - total) / 2
for ln in lines:
w = _text_width(font, ln)
if align == "left":
x = x0
elif align == "right":
x = x1 - w
else:
x = x0 + (x1 - x0 - w) / 2
draw.text((x, y), ln, font=font, fill=color)
y += lh
# ── style ─────────────────────────────────────────────────────────────────
@dataclass
class Style:
position: str = "top"
align: str = "center"
font: str = DEFAULT_FONT
#: 0 = automatic (a share of the image width).
font_size: int = 0
text_color: str = "#ffffff"
label_color: str = "#000000"
background: str = "#ffffff"
header_color: str = "#000000"
gap: int = 8
margin: int = 0
#: 0 = keep each image's size; otherwise every cell is scaled to this width.
cell_width: int = 0
def auto_size(self, width: int) -> int:
if self.font_size > 0:
return self.font_size
return max(12, int(round(width * 0.045)))
@dataclass
class GridSpec:
arrange: str = "rows"
columns: int = 0
title: str = ""
column_headers: list[str] = field(default_factory=list)
row_headers: list[str] = field(default_factory=list)
#: Label part (from 1, negative from the end) that sets each cell's column
#: / row; 0 = not used.
column_part: int = 0
row_part: int = 0
# ── single image ──────────────────────────────────────────────────────────
def _padding(size: int) -> int:
return max(4, size // 3)
def label_image(img: Image.Image, text: str, style: Style, strip: int | None = None) -> Image.Image:
"""``img`` with ``text`` drawn on a strip beside it (top / bottom / left /
right) or on a band over it. ``strip`` forces the strip's size so a batch
keeps one shape; ``None`` fits the text. No text and no forced strip → the
image unchanged."""
if style.position == "none" or (not text and not strip):
return img
w, h = img.size
size = style.auto_size(w)
font = load_font(style.font, size)
pad = _padding(size)
fg = parse_color(style.text_color, (255, 255, 255, 255))
bg = parse_color(style.label_color, (0, 0, 0, 255))
pos = style.position
if pos in ("left", "right"):
side = strip or label_strip_size(text, w, h, style)
out = Image.new("RGB", (w + side, h), _rgb(bg, style.background))
out.paste(img, (side if pos == "left" else 0, 0))
lines = wrap(text, font, side - 2 * pad, MAX_LABEL_LINES * 3)
x0 = 0 if pos == "left" else w
draw_lines(
ImageDraw.Draw(out),
lines,
font,
(x0 + pad, pad, x0 + side - pad, h - pad),
fg,
style.align,
)
return out
lines = wrap(text, font, w - 2 * pad, MAX_LABEL_LINES)
band = strip or label_strip_size(text, w, h, style)
if pos in ("top", "bottom"):
out = Image.new("RGB", (w, h + band), _rgb(bg, style.background))
out.paste(img, (0, band if pos == "top" else 0))
y0 = 0 if pos == "top" else h
draw_lines(ImageDraw.Draw(out), lines, font, (pad, y0, w - pad, y0 + band), fg, style.align)
return out
# Over the image: a (possibly translucent) band.
out = img.convert("RGBA")
if not text:
return img
overlay = Image.new("RGBA", out.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
y0 = 0 if pos == "over_top" else h - band
draw.rectangle((0, y0, w, y0 + band), fill=bg)
draw_lines(draw, lines, font, (pad, y0, w - pad, y0 + band), fg, style.align)
return Image.alpha_composite(out, overlay).convert("RGB")
def label_strip_size(text: str, w: int, h: int, style: Style) -> int:
"""The strip a label needs: its height (top / bottom / over) or width
(left / right)."""
if style.position == "none" or not text:
return 0
size = style.auto_size(w)
font = load_font(style.font, size)
pad = _padding(size)
if style.position in ("left", "right"):
longest = max((_text_width(font, ln) for ln in text.split("\n")), default=0)
return int(min(longest + 2 * pad + 1, max(w * 0.6, 4 * size)))
lines = wrap(text, font, w - 2 * pad, MAX_LABEL_LINES)
return text_block_height(lines, font) + 2 * pad
def label_batch(images: list[Image.Image], texts: list[str], style: Style) -> list[Image.Image]:
"""Label every image of one batch with ONE strip size so the batch keeps
one shape."""
if not images:
return []
w, h = images[0].size
strip = max((label_strip_size(t, w, h, style) for t in texts), default=0)
if strip == 0:
return list(images)
return [label_image(img, t, style, strip) for img, t in zip(images, texts, strict=False)]
def _rgb(color: tuple[int, int, int, int], background: str) -> tuple[int, int, int]:
"""An RGBA strip colour flattened onto the background (strips are opaque
once they sit outside the image)."""
if color[3] == 255:
return color[:3]
base = parse_color(background, (255, 255, 255, 255))
a = color[3] / 255
return tuple(int(round(c * a + b * (1 - a))) for c, b in zip(color[:3], base[:3], strict=True)) # type: ignore[return-value]
# ── grid ──────────────────────────────────────────────────────────────────
@dataclass
class Cell:
image: Image.Image
label: str
item: int
index: int
def _scale(img: Image.Image, width: int) -> Image.Image:
if width <= 0 or img.width == width:
return img
height = max(1, int(round(img.height * width / img.width)))
return img.resize((width, height), Image.Resampling.LANCZOS)
Grid = list[list["Cell | None"]]
def arrange(cells: list[Cell], spec: GridSpec) -> tuple[Grid, list[str], list[str]]:
"""Place the cells: ``rows[r][c]``, plus the column and row headers.
With a label part as a header, rows / columns are that part's distinct
values in first-seen order (an XY plot); otherwise the arrange mode and the
typed headers apply.
"""
col_vals: list[str] = []
row_vals: list[str] = []
if spec.column_part or spec.row_part:
for cell in cells:
if spec.column_part:
v = part(cell.label, spec.column_part)
if v not in col_vals:
col_vals.append(v)
if spec.row_part:
v = part(cell.label, spec.row_part)
if v not in row_vals:
row_vals.append(v)
if spec.column_part and spec.row_part:
spots: dict[tuple[int, int], list[Cell]] = {}
for cell in cells:
r = row_vals.index(part(cell.label, spec.row_part))
c = col_vals.index(part(cell.label, spec.column_part))
spots.setdefault((r, c), []).append(cell)
# Several cells on one spot (same options, another seed): the row
# repeats, once per extra cell, under the same header.
grid: list[list[Cell | None]] = []
heads: list[str] = []
for r, value in enumerate(row_vals):
depth = max((len(spots.get((r, c), [])) for c in range(len(col_vals))), default=1)
for k in range(max(1, depth)):
grid.append(
[
spots[(r, c)][k] if k < len(spots.get((r, c), [])) else None
for c in range(len(col_vals))
]
)
heads.append(value)
return grid, col_vals, heads
if spec.column_part:
grid = _group(cells, col_vals, lambda c: part(c.label, spec.column_part), by_column=True)
return grid, col_vals, list(spec.row_headers)
if spec.row_part:
grid = _group(cells, row_vals, lambda c: part(c.label, spec.row_part), by_column=False)
return grid, list(spec.column_headers), row_vals
items = sorted({c.item for c in cells})
mode = spec.arrange
if mode == "auto":
mode = auto_arrange([sum(1 for c in cells if c.item == it) for it in items])
if mode == "strip":
return [list(cells)], list(spec.column_headers), list(spec.row_headers)
if mode == "stack":
return [[c] for c in cells], list(spec.column_headers), list(spec.row_headers)
if mode == "fill" or len(items) == 1 and spec.columns > 0:
cols = spec.columns if spec.columns > 0 else _auto_columns(len(cells))
grid = [list(cells[i : i + cols]) for i in range(0, len(cells), cols)]
_pad_rows(grid)
return grid, list(spec.column_headers), list(spec.row_headers)
if len(items) == 1:
# One batch, no column count: a near-square grid.
cols = _auto_columns(len(cells))
grid = [list(cells[i : i + cols]) for i in range(0, len(cells), cols)]
_pad_rows(grid)
return grid, list(spec.column_headers), list(spec.row_headers)
per_item = [[c for c in cells if c.item == it] for it in items]
grid = [list(r) for r in per_item]
_pad_rows(grid)
if mode == "columns":
grid = [list(col) for col in zip(*grid, strict=True)]
return grid, list(spec.column_headers), list(spec.row_headers)
def _group(cells: list[Cell], keys: list[str], key: Any, by_column: bool) -> Grid:
lanes: list[list[Cell]] = [[] for _ in keys]
for cell in cells:
lanes[keys.index(key(cell))].append(cell)
depth = max((len(lane) for lane in lanes), default=0)
padded: list[list[Cell | None]] = [[*lane, *([None] * (depth - len(lane)))] for lane in lanes]
if by_column:
return [list(row) for row in zip(*padded, strict=True)] if padded else []
return padded
def _pad_rows(grid: list[list[Any]]) -> None:
width = max((len(r) for r in grid), default=0)
for row in grid:
row.extend([None] * (width - len(row)))
def _auto_columns(n: int) -> int:
cols = 1
while cols * cols < n:
cols += 1
return cols
def build_grid(cells: list[Cell], style: Style, spec: GridSpec) -> Image.Image:
"""The grid image: optional title, column headers on top, row headers on
the left, every cell labelled per ``style`` (labels without the parts
lifted into headers)."""
if not cells:
raise ValueError("no images to lay out")
lifted = [p for p in (spec.column_part, spec.row_part) if p]
scaled = [
Cell(_scale(c.image.convert("RGB"), style.cell_width), c.label, c.item, c.index)
for c in cells
]
grid, col_heads, row_heads = arrange(scaled, spec)
rows, cols = len(grid), max((len(r) for r in grid), default=0)
cw = max(c.image.width for c in scaled)
ch = max(c.image.height for c in scaled)
labels = {id(c): drop_parts(c.label, lifted) if lifted else c.label for c in scaled}
strip = max((label_strip_size(labels[id(c)], cw, ch, style) for c in scaled), default=0)
side = style.position in ("left", "right")
cell_w = cw + (strip if side else 0)
cell_h = ch + (strip if style.position in ("top", "bottom") else 0)
hsize = style.auto_size(cw)
hfont = load_font(style.font, int(round(hsize * 1.25)))
hpad = _padding(hsize)
col_lines = [wrap(t, hfont, cell_w - 2 * hpad, MAX_HEADER_LINES) for t in col_heads[:cols]]
top = max((text_block_height(ls, hfont) for ls in col_lines), default=0)
top = top + 2 * hpad if any(col_heads[:cols]) else 0
rh = [t for t in row_heads[:rows]]
left = 0
if any(rh):
longest = max((_text_width(hfont, ln) for t in rh for ln in t.split("\n")), default=0)
left = int(min(longest + 2 * hpad + 1, max(cell_w, 6 * hsize)))
row_lines = [wrap(t, hfont, left - 2 * hpad, MAX_HEADER_LINES * 2) for t in rh] if left else []
tfont = load_font(style.font, int(round(hsize * 1.6)))
gap, m = max(0, style.gap), max(0, style.margin)
body_w = left + cols * cell_w + (cols - 1) * gap
title_lines = wrap(spec.title, tfont, body_w - 2 * hpad, 3) if spec.title.strip() else []
title_h = text_block_height(title_lines, tfont) + 2 * hpad if title_lines else 0
width = 2 * m + body_w
height = 2 * m + title_h + top + rows * cell_h + (rows - 1) * gap
bg = parse_color(style.background, (255, 255, 255, 255))
fill = bg[:3] if bg[3] else (255, 255, 255)
out = Image.new("RGB", (width, height), fill)
draw = ImageDraw.Draw(out)
hc = parse_color(style.header_color, (0, 0, 0, 255))
if title_lines:
draw_lines(draw, title_lines, tfont, (m + hpad, m, width - m - hpad, m + title_h), hc)
y_body = m + title_h + top
x_body = m + left
for c, lines in enumerate(col_lines):
x = x_body + c * (cell_w + gap)
draw_lines(
draw,
lines,
hfont,
(x + hpad, m + title_h, x + cell_w - hpad, y_body),
hc,
valign="center",
)
for r, lines in enumerate(row_lines):
y = y_body + r * (cell_h + gap)
draw_lines(draw, lines, hfont, (m + hpad, y, x_body - hpad, y + cell_h), hc, align="right")
for r, row in enumerate(grid):
for c, cell in enumerate(row):
if cell is None:
continue
img = cell.image
framed = Image.new("RGB", (cw, ch), fill)
framed.paste(img, ((cw - img.width) // 2, (ch - img.height) // 2))
tile = label_image(framed, labels[id(cell)], style, strip or None) if strip else framed
out.paste(tile, (x_body + c * (cell_w + gap), y_body + r * (cell_h + gap)))
longest_side = max(out.size)
if longest_side > MAX_GRID_SIDE:
k = MAX_GRID_SIDE / longest_side
out = out.resize(
(max(1, int(out.width * k)), max(1, int(out.height * k))), Image.Resampling.LANCZOS
)
return out