#!/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())