Files
wiki/skills/diagram-table/scripts/generate_table.py
2026-04-12 21:55:33 +03:00

403 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
import textwrap
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Sequence
from PIL import Image, ImageDraw, ImageFont
FONT_REGULAR = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
FONT_BOLD = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")
DEFAULT_WIDTH = 800
MIN_WIDTH = 400
MAX_WIDTH = 2400
MAX_ROWS = 100
MAX_COLUMNS = 20
ROWS_PER_PAGE = 20
TITLE_FONT_SIZE = 16
CELL_FONT_SIZE = 14
PADDING_X = 14
PADDING_Y = 10
CELL_GAP = 1
OUTER_MARGIN = 24
TITLE_GAP = 16
LINE_SPACING = 4
@dataclass(frozen=True)
class Theme:
background: str
header: str
row_even: str
row_odd: str
text: str
border: str
THEMES = {
"dark": Theme(
background="#0d1117",
header="#1a3a2a",
row_even="#161b22",
row_odd="#0d1117",
text="#e6edf3",
border="#30363d",
),
"light": Theme(
background="#ffffff",
header="#238636",
row_even="#f6f8fa",
row_odd="#ffffff",
text="#24292f",
border="#d0d7de",
),
}
class ValidationError(ValueError):
pass
def load_font(path: Path, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
try:
return ImageFont.truetype(str(path), size=size)
except OSError:
return ImageFont.load_default()
def read_payload() -> dict[str, Any]:
raw = sys.stdin.read().strip()
if not raw:
raise ValidationError("Expected JSON payload on stdin")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValidationError(f"Invalid JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValidationError("Top-level JSON value must be an object")
return payload
def validate_payload(payload: dict[str, Any]) -> dict[str, Any]:
headers = payload.get("headers")
rows = payload.get("rows")
title = payload.get("title")
width = payload.get("width", DEFAULT_WIDTH)
theme = payload.get("theme", "dark")
if not isinstance(headers, list) or not headers:
raise ValidationError("'headers' must be a non-empty list")
if len(headers) > MAX_COLUMNS:
raise ValidationError(f"Too many columns: {len(headers)} > {MAX_COLUMNS}")
if not all(isinstance(item, (str, int, float, bool)) or item is None for item in headers):
raise ValidationError("All headers must be scalar values")
if not isinstance(rows, list) or not rows:
raise ValidationError("'rows' must be a non-empty list")
if len(rows) > MAX_ROWS:
raise ValidationError(f"Too many rows: {len(rows)} > {MAX_ROWS}")
normalized_rows: list[list[str]] = []
expected_len = len(headers)
for index, row in enumerate(rows, start=1):
if not isinstance(row, list):
raise ValidationError(f"Row {index} must be a list")
if len(row) != expected_len:
raise ValidationError(
f"Row {index} has {len(row)} cells, expected {expected_len}"
)
normalized_rows.append(["" if cell is None else str(cell) for cell in row])
if title is not None and not isinstance(title, str):
raise ValidationError("'title' must be a string if provided")
if not isinstance(width, int):
raise ValidationError("'width' must be an integer")
width = max(MIN_WIDTH, min(MAX_WIDTH, width))
if theme not in THEMES:
raise ValidationError("'theme' must be 'dark' or 'light'")
return {
"headers": [str(item) for item in headers],
"rows": normalized_rows,
"title": title.strip() if isinstance(title, str) else None,
"width": width,
"theme": theme,
}
def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def line_height(draw: ImageDraw.ImageDraw, font: ImageFont.ImageFont) -> int:
bbox = draw.textbbox((0, 0), "Ag", font=font)
return bbox[3] - bbox[1]
def wrap_cell(
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.ImageFont,
max_width: int,
) -> list[str]:
content = text.strip()
if not content:
return [""]
paragraphs = content.splitlines() or [content]
wrapped: list[str] = []
for paragraph in paragraphs:
words = paragraph.split()
if not words:
wrapped.append("")
continue
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if text_width(draw, candidate, font) <= max_width:
current = candidate
continue
wrapped.extend(split_long_token(draw, current, font, max_width))
current = word
wrapped.extend(split_long_token(draw, current, font, max_width))
return wrapped or [""]
def split_long_token(
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.ImageFont,
max_width: int,
) -> list[str]:
if text_width(draw, text, font) <= max_width:
return [text]
chunks: list[str] = []
current = ""
for char in text:
candidate = f"{current}{char}"
if current and text_width(draw, candidate, font) > max_width:
chunks.append(current)
current = char
else:
current = candidate
if current:
chunks.append(current)
return chunks or [text]
def compute_column_widths(
total_width: int,
headers: Sequence[str],
rows: Sequence[Sequence[str]],
draw: ImageDraw.ImageDraw,
font: ImageFont.ImageFont,
header_font: ImageFont.ImageFont,
) -> list[int]:
content_width = total_width - (2 * OUTER_MARGIN)
widths = [text_width(draw, header, header_font) + (2 * PADDING_X) for header in headers]
sample_rows = rows[: min(len(rows), ROWS_PER_PAGE)]
for row in sample_rows:
for index, cell in enumerate(row):
widths[index] = max(widths[index], text_width(draw, cell, font) + (2 * PADDING_X))
total = sum(widths)
if total <= content_width:
slack = content_width - total
if slack > 0:
widths[-1] += slack
return widths
ratios = [max(width, 80) for width in widths]
ratio_total = sum(ratios)
scaled = [max(80, int(content_width * ratio / ratio_total)) for ratio in ratios]
difference = content_width - sum(scaled)
scaled[-1] += difference
if scaled[-1] < 80:
scaled[-1] = 80
overflow = sum(scaled) - content_width
for index in range(len(scaled) - 2, -1, -1):
reducible = max(0, scaled[index] - 80)
reduction = min(reducible, overflow)
scaled[index] -= reduction
overflow -= reduction
if overflow == 0:
break
return scaled
def row_height(
wrapped_cells: Sequence[Sequence[str]],
draw: ImageDraw.ImageDraw,
font: ImageFont.ImageFont,
) -> int:
text_h = line_height(draw, font)
max_lines = max(len(cell) for cell in wrapped_cells)
content_h = (text_h * max_lines) + (LINE_SPACING * max(0, max_lines - 1))
return content_h + (2 * PADDING_Y)
def draw_row(
draw: ImageDraw.ImageDraw,
top: int,
widths: Sequence[int],
wrapped_cells: Sequence[Sequence[str]],
font: ImageFont.ImageFont,
text_color: str,
fill: str,
border: str,
) -> int:
height = row_height(wrapped_cells, draw, font)
x = OUTER_MARGIN
for width, lines in zip(widths, wrapped_cells):
draw.rectangle(
[(x, top), (x + width, top + height)],
fill=fill,
outline=border,
width=1,
)
y = top + PADDING_Y
for line in lines:
draw.text((x + PADDING_X, y), line, fill=text_color, font=font)
y += line_height(draw, font) + LINE_SPACING
x += width - CELL_GAP
return height
def render_table(payload: dict[str, Any]) -> Path:
headers: list[str] = payload["headers"]
rows: list[list[str]] = payload["rows"]
title: str | None = payload["title"]
width: int = payload["width"]
theme = THEMES[payload["theme"]]
title_font = load_font(FONT_BOLD, TITLE_FONT_SIZE)
header_font = load_font(FONT_BOLD, CELL_FONT_SIZE)
cell_font = load_font(FONT_REGULAR, CELL_FONT_SIZE)
measure_image = Image.new("RGB", (width, 200), theme.background)
measure_draw = ImageDraw.Draw(measure_image)
column_widths = compute_column_widths(width, headers, rows, measure_draw, cell_font, header_font)
row_pages = [rows[i : i + ROWS_PER_PAGE] for i in range(0, len(rows), ROWS_PER_PAGE)]
title_block_height = 0
if title:
title_block_height = line_height(measure_draw, title_font) + TITLE_GAP
header_wrapped = [
wrap_cell(measure_draw, header, header_font, col_width - (2 * PADDING_X))
for header, col_width in zip(headers, column_widths)
]
header_height = row_height(header_wrapped, measure_draw, header_font)
page_heights: list[int] = []
wrapped_pages: list[list[list[list[str]]]] = []
for page in row_pages:
wrapped_rows = [
[
wrap_cell(measure_draw, cell, cell_font, col_width - (2 * PADDING_X))
for cell, col_width in zip(row, column_widths)
]
for row in page
]
wrapped_pages.append(wrapped_rows)
total_height = OUTER_MARGIN + title_block_height + header_height
for wrapped in wrapped_rows:
total_height += row_height(wrapped, measure_draw, cell_font) - CELL_GAP
total_height += OUTER_MARGIN
page_heights.append(total_height)
total_height = sum(page_heights) + (OUTER_MARGIN if len(page_heights) > 1 else 0) * (len(page_heights) - 1)
image = Image.new("RGB", (width, total_height), theme.background)
draw = ImageDraw.Draw(image)
current_top = 0
for page_index, wrapped_rows in enumerate(wrapped_pages, start=1):
y = current_top + OUTER_MARGIN
page_title = title
if title and len(wrapped_pages) > 1:
page_title = f"{title} — часть {page_index}/{len(wrapped_pages)}"
if page_title:
draw.text((OUTER_MARGIN, y), page_title, fill=theme.text, font=title_font)
y += line_height(draw, title_font) + TITLE_GAP
draw_row(
draw,
y,
column_widths,
header_wrapped,
header_font,
theme.text,
theme.header,
theme.border,
)
y += header_height - CELL_GAP
for row_index, wrapped in enumerate(wrapped_rows):
fill = theme.row_even if row_index % 2 == 0 else theme.row_odd
height = draw_row(
draw,
y,
column_widths,
wrapped,
cell_font,
theme.text,
fill,
theme.border,
)
y += height - CELL_GAP
current_top += page_heights[page_index - 1]
if page_index < len(wrapped_pages):
current_top += OUTER_MARGIN
output_path = Path("/tmp") / f"table_{datetime.utcnow().strftime('%Y%m%d%H%M%S%f')}.png"
image.save(output_path, format="PNG")
return output_path
def main() -> int:
try:
payload = validate_payload(read_payload())
output_path = render_table(payload)
except ValidationError as exc:
print(str(exc), file=sys.stderr)
return 1
except Exception as exc: # pragma: no cover - defensive CLI handling
print(f"Unexpected error: {exc}", file=sys.stderr)
return 1
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())