"""GOST PDF export — table layout aligned with desktop PDFController.""" from __future__ import annotations from dataclasses import dataclass from io import BytesIO from pathlib import Path from typing import Any, Literal from reportlab.lib.pagesizes import A3, A4, landscape from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas from app.core.config import get_settings _font_registered = False FONT_NAME = "GOST_A" FONT_SIZE = 12 LINE_WIDTH = 0.5 * mm Align = Literal["left", "center", "right"] @dataclass(frozen=True) class ColSpec: field: str width_mm: float header: str align: Align = "center" rotate_header: bool = False @dataclass(frozen=True) class TableLayout: columns: tuple[ColSpec, ...] row_height_mm: float header_height_mm: float rows_first: int rows_other: int page_size: tuple[float, float] margin_left_mm: float = 20.0 margin_top_mm: float = 5.0 margin_right_mm: float = 5.0 DOC_TITLES = { "perechen": "Перечень элементов", "specification_pcb": "Спецификация", "specification": "Спецификация", "vedomost": "Ведомость покупных изделий", } # Dimensions from controller/pdfcontroller.cpp (add*TableRange) TABLE_LAYOUTS: dict[str, TableLayout] = { "perechen": TableLayout( columns=( ColSpec("position", 20, "Поз.\nобозначение"), ColSpec("designation", 110, "Наименование"), ColSpec("quantity", 10, "Кол."), ColSpec("note", 45, "Примечание"), ), row_height_mm=8.0, header_height_mm=15.0, rows_first=25, rows_other=32, page_size=A4, ), "specification_pcb": TableLayout( columns=( ColSpec("format", 6, "Формат", rotate_header=True), ColSpec("zone", 6, "Зона", rotate_header=True), ColSpec("position", 8, "Поз.", rotate_header=True), ColSpec("designation", 70, "Обозначение"), ColSpec("name", 63, "Наименование"), ColSpec("quantity", 10, "Кол."), ColSpec("note", 22, "Примечание"), ), row_height_mm=8.035, header_height_mm=15.0, rows_first=26, rows_other=32, page_size=A4, ), "specification": TableLayout( columns=( ColSpec("format", 6, "Формат", rotate_header=True), ColSpec("zone", 6, "Зона", rotate_header=True), ColSpec("position", 8, "Поз.", rotate_header=True), ColSpec("designation", 70, "Обозначение"), ColSpec("name", 63, "Наименование"), ColSpec("quantity", 10, "Кол."), ColSpec("note", 22, "Примечание"), ), row_height_mm=8.035, header_height_mm=15.0, rows_first=26, rows_other=32, page_size=A4, ), "vedomost": TableLayout( columns=( ColSpec("_rownum", 7, "№\nстроки", rotate_header=True), ColSpec("name", 60, "Наименование"), ColSpec("product_code", 45, "Код продукции"), ColSpec("document_code", 70, "Обозначение документа\nна поставку"), ColSpec("supplier", 55, "Поставщик"), ColSpec("where_used", 70, "Куда входит\n(обозначение)"), ColSpec("quantity_per_item", 16, "На\nизделие", rotate_header=True), ColSpec("quantity_in_set", 16, "В\nкомплекте", rotate_header=True), ColSpec("quantity_for_reg", 16, "На\nрегулир.", rotate_header=True), ColSpec("total_quantity", 16, "Всего", rotate_header=True), ColSpec("note", 24, "Примечание"), ), row_height_mm=8.17, header_height_mm=27.0, rows_first=24, rows_other=29, page_size=landscape(A3), ), } def _ensure_font() -> str: global _font_registered if not _font_registered: path = Path(get_settings().font_path) if path.exists(): pdfmetrics.registerFont(TTFont(FONT_NAME, str(path))) _font_registered = True return FONT_NAME return "Helvetica" return FONT_NAME if Path(get_settings().font_path).exists() else "Helvetica" def _total_pages(row_count: int, rows_first: int, rows_other: int) -> int: if row_count <= 0: return 1 pages = 1 remaining = row_count remaining -= rows_first while remaining > 0: pages += 1 remaining -= rows_other return pages def _paginate_rows( rows: list[dict[str, Any]], rows_first: int, rows_other: int ) -> list[list[dict[str, Any] | None]]: if not rows: return [[None] * rows_first] pages: list[list[dict[str, Any] | None]] = [] current = 0 page_num = 1 while current < len(rows) or not pages: limit = rows_first if page_num == 1 else rows_other chunk: list[dict[str, Any] | None] = list(rows[current : current + limit]) is_last = current + limit >= len(rows) if is_last: chunk.extend([None] * (limit - len(chunk))) pages.append(chunk) break pages.append(chunk) current += limit page_num += 1 return pages def _elide(c: canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str: if not text: return "" if c.stringWidth(text, font, size) <= max_w: return text ell = "…" t = text while t and c.stringWidth(t + ell, font, size) > max_w: t = t[:-1] return (t + ell) if t else ell def _draw_text_in_rect( c: canvas.Canvas, x: float, y: float, w: float, h: float, text: str, font: str, size: float, align: Align, underline: bool = False, multiline_header: bool = False, ) -> None: if not text: return pad = 0.8 * mm c.setFont(font, size) lines = text.split("\n") if multiline_header else [text] line_step = size * 1.15 block_h = len(lines) * line_step base_y = y + (h - block_h) / 2 + size * 0.28 for i, line in enumerate(lines): line = _elide(c, line.strip(), font, size, w - 2 * pad) if not line: continue tw = c.stringWidth(line, font, size) if align == "center": tx = x + (w - tw) / 2 elif align == "right": tx = x + w - tw - pad else: tx = x + pad ty = base_y + (len(lines) - 1 - i) * line_step c.drawString(tx, ty, line) if underline: c.line(tx, ty - 0.6 * mm, tx + tw, ty - 0.6 * mm) def _draw_rotated_header( c: canvas.Canvas, x: float, y: float, w: float, h: float, text: str, font: str, size: float ) -> None: c.saveState() c.translate(x, y) c.rotate(-90) c.setFont(font, size) lines = text.split("\n") line = lines[0] if lines else text line = _elide(c, line, font, size, h - mm) tw = c.stringWidth(line, font, size) c.drawString((h - tw) / 2, (w - size * 0.35) / 2, line) c.restoreState() def _draw_frame( c: canvas.Canvas, w: float, h: float, page: int, total: int, inscriptions: dict[int, str], doc_type: str, ) -> None: font = _ensure_font() margin_left = 20 * mm margin_right = 5 * mm margin_top = 5 * mm margin_bottom = 5 * mm c.setLineWidth(0.8) c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom) block_h = 55 * mm if page == 1 else 15 * mm block_w = 185 * mm x0 = w - margin_right - block_w y0 = margin_bottom c.setLineWidth(0.5) c.rect(x0, y0, block_w, block_h) c.setFont(font, 8) name = inscriptions.get(1, "") designation = inscriptions.get(2, "") org = inscriptions.get(9, "") title = DOC_TITLES.get(doc_type, "") c.drawString(x0 + 2 * mm, y0 + block_h - 6 * mm, title) c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, (designation or "")[:80]) c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, (name or "")[:80]) if page == 1: c.drawString(x0 + 2 * mm, y0 + 8 * mm, (org or "")[:50]) c.drawString(x0 + 2 * mm, y0 + 3 * mm, f"Разраб. {inscriptions.get(111, '')}") c.drawString(x0 + 50 * mm, y0 + 3 * mm, f"Пров. {inscriptions.get(112, '')}") c.drawRightString(x0 + block_w - 2 * mm, y0 + 3 * mm, f"Лист {page}/{total}") def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str: if row is None: return "" if field == "_rownum": return str(display_index + 1) if row.get("is_empty"): return "" return str(row.get(field, "") or "") def _draw_table_page( c: canvas.Canvas, layout: TableLayout, page_rows: list[dict[str, Any] | None], page_h: float, ) -> None: font = _ensure_font() c.setLineWidth(LINE_WIDTH) table_x = layout.margin_left_mm * mm table_top = page_h - layout.margin_top_mm * mm row_h = layout.row_height_mm * mm header_h = layout.header_height_mm * mm # Header row cx = table_x for col in layout.columns: cw = col.width_mm * mm cell_y = table_top - header_h c.rect(cx, cell_y, cw, header_h) if col.rotate_header: _draw_rotated_header(c, cx, cell_y + header_h, cw, header_h, col.header, font, FONT_SIZE) else: _draw_text_in_rect( c, cx, cell_y, cw, header_h, col.header, font, FONT_SIZE, "center", multiline_header="\n" in col.header, ) cx += cw # Data rows for display_i, row in enumerate(page_rows): cy = table_top - header_h - (display_i + 1) * row_h cx = table_x for col in layout.columns: cw = col.width_mm * mm c.rect(cx, cy, cw, row_h) text = _cell_value(row, col.field, display_i) is_header = bool(row and row.get("is_header")) underline = bool(row and row.get("is_underline")) align: Align = col.align if col.field in ("designation", "name"): align = "center" if is_header else "left" _draw_text_in_rect( c, cx, cy, cw, row_h, text, font, FONT_SIZE, align, underline=underline or is_header ) cx += cw def export_pdf( table_type: str, rows: list[dict[str, Any]], inscriptions: dict[int, str] | None = None, ) -> bytes: if table_type == "simple_list": raise ValueError("PDF export is not available for simple_list") if table_type not in TABLE_LAYOUTS: raise ValueError(f"Unknown table type: {table_type}") layout = TABLE_LAYOUTS[table_type] inscriptions = inscriptions or {} pages = _paginate_rows(rows, layout.rows_first, layout.rows_other) total = _total_pages(len(rows), layout.rows_first, layout.rows_other) buf = BytesIO() page_w, page_h = layout.page_size c = canvas.Canvas(buf, pagesize=layout.page_size) for page_idx, page_rows in enumerate(pages, start=1): _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type) _draw_table_page(c, layout, page_rows, page_h) if page_idx < len(pages): c.showPage() c.save() return buf.getvalue()