403 lines
12 KiB
Python
Executable File
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())
|