"""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 from app.services.pdf_gost_frame import draw_gost_frame _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() @dataclass class PdfExportResult: data: bytes overflows: list[dict[str, Any]] def _cell_stretch(row: dict[str, Any] | None, default: int) -> int: if not row: return default meta = row.get("cell_stretches") or {} if isinstance(meta, dict) and meta: return default if row.get("stretch"): return min(default, 85) return default def _draw_cell_with_stretch( c: canvas.Canvas, x: float, y: float, w: float, h: float, text: str, font: str, size: float, align: Align, cell_stretch: int, underline: bool, overflow_sink: list[dict[str, Any]] | None, row: dict[str, Any] | None, field: str, global_row_index: int, ) -> None: if not text: return pad = 0.8 * mm inner_w = max(w - 2 * pad, 1) scale = cell_stretch / 100.0 c.setFont(font, size) text_w = c.stringWidth(text, font, size) * scale display = text is_overflow = False if text_w > inner_w: max_before_scale = inner_w / scale if scale > 0 else inner_w display = _elide(c, text, font, size, max_before_scale) is_overflow = True if overflow_sink is not None and row is not None: overflow_sink.append( { "row_id": row.get("id"), "row_index": row.get("row_index", global_row_index), "field": field, } ) if scale != 1.0 and not is_overflow: c.saveState() if align == "center": ox = x + w / 2 elif align == "right": ox = x + w - pad else: ox = x + pad c.translate(ox, y + h / 2) c.scale(scale, 1) tw = c.stringWidth(display, font, size) tx = -tw / 2 if align == "center" else (-tw if align == "right" else 0) c.drawString(tx, -size * 0.28, display) c.restoreState() else: _draw_text_in_rect(c, x, y, w, h, display, font, size, align, underline=underline) def _draw_frame( c: canvas.Canvas, w: float, h: float, page: int, total: int, inscriptions: dict[int, str], table_type: str, font: str, ) -> None: draw_gost_frame(c, w, h, inscriptions, table_type, page, total, font) 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, font_stretch: int, global_row_start: int, overflows: list[dict[str, Any]], ) -> 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 global_idx = global_row_start + display_i 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", "product_code", "document_code", "supplier", "note", "where_used"): align = "center" if is_header else "left" stretch = _cell_stretch(row, font_stretch) pad_x = cx + (2 * mm if col.field in ("designation", "name") and not is_header else 0) _draw_cell_with_stretch( c, pad_x, cy, cw - (2 * mm if pad_x > cx else 0), row_h, text, font, FONT_SIZE, align, stretch, underline or is_header, overflows, row, col.field, global_idx, ) cx += cw def export_pdf( table_type: str, rows: list[dict[str, Any]], inscriptions: dict[int, str] | None = None, font_stretch: int = 100, ) -> PdfExportResult: 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) overflows: list[dict[str, Any]] = [] font = _ensure_font() buf = BytesIO() page_w, page_h = layout.page_size c = canvas.Canvas(buf, pagesize=layout.page_size) row_offset = 0 for page_idx, page_rows in enumerate(pages, start=1): _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type, font) _draw_table_page(c, layout, page_rows, page_h, font_stretch, row_offset, overflows) row_offset += layout.rows_first if page_idx == 1 else layout.rows_other if page_idx < len(pages): c.showPage() c.save() return PdfExportResult(data=buf.getvalue(), overflows=overflows)