diff --git a/web/backend/app/services/pdf_export.py b/web/backend/app/services/pdf_export.py index 5b73ad9..ec9ec8a 100644 --- a/web/backend/app/services/pdf_export.py +++ b/web/backend/app/services/pdf_export.py @@ -1,12 +1,13 @@ -"""Simplified GOST A4 PDF export with frame and table.""" +"""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 +from typing import Any, Literal -from reportlab.lib.pagesizes import A4 +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 @@ -16,6 +17,109 @@ 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: @@ -30,55 +134,115 @@ def _ensure_font() -> str: return FONT_NAME if Path(get_settings().font_path).exists() else "Helvetica" -DOC_TITLES = { - "perechen": "Перечень элементов", - "specification_pcb": "Спецификация", - "specification": "Спецификация", - "vedomost": "Ведомость покупных изделий", -} - -COLUMNS = { - "perechen": [ - ("position", 30 * mm, "Поз."), - ("designation", 100 * mm, "Наименование"), - ("quantity", 15 * mm, "Кол."), - ("note", 35 * mm, "Прим."), - ], - "specification_pcb": [ - ("format", 12 * mm, "Форм."), - ("zone", 10 * mm, "Зона"), - ("position", 10 * mm, "Поз."), - ("designation", 40 * mm, "Обозн."), - ("name", 55 * mm, "Наименование"), - ("quantity", 12 * mm, "Кол."), - ("note", 30 * mm, "Прим."), - ], - "specification": [ - ("format", 12 * mm, "Форм."), - ("zone", 10 * mm, "Зона"), - ("position", 10 * mm, "Поз."), - ("designation", 40 * mm, "Обозн."), - ("name", 55 * mm, "Наименование"), - ("quantity", 12 * mm, "Кол."), - ("note", 30 * mm, "Прим."), - ], - "vedomost": [ - ("name", 40 * mm, "Наименование"), - ("product_code", 20 * mm, "Код"), - ("document_code", 25 * mm, "Док."), - ("supplier", 20 * mm, "Пост."), - ("where_used", 20 * mm, "Куда"), - ("quantity_per_item", 12 * mm, "На изд."), - ("total_quantity", 12 * mm, "Всего"), - ("note", 20 * mm, "Прим."), - ], -} - -ROWS_FIRST = {"perechen": 25, "specification_pcb": 26, "specification": 27, "vedomost": 24} -ROWS_OTHER = {"perechen": 32, "specification_pcb": 32, "specification": 33, "vedomost": 29} +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 _draw_frame(c: canvas.Canvas, w: float, h: float, page: int, total: int, inscriptions: dict[int, str], doc_type: str): +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 @@ -87,7 +251,6 @@ def _draw_frame(c: canvas.Canvas, w: float, h: float, page: int, total: int, ins c.setLineWidth(0.8) c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom) - # Title block (simplified bottom-right) block_h = 55 * mm if page == 1 else 15 * mm block_w = 185 * mm x0 = w - margin_right - block_w @@ -100,27 +263,80 @@ def _draw_frame(c: canvas.Canvas, w: float, h: float, page: int, total: int, ins designation = inscriptions.get(2, "") org = inscriptions.get(9, "") title = DOC_TITLES.get(doc_type, "") - c.drawString(x0 + 2 * mm, y0 + block_h - 6 * mm, f"{title}") - c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, designation[:60]) - c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, name[:60]) + 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[:40]) + 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 _chunk_rows(rows: list[dict[str, Any]], first: int, other: int) -> list[list[dict[str, Any]]]: - if not rows: - return [[]] - pages: list[list[dict[str, Any]]] = [] - i = 0 - limit = first - while i < len(rows): - pages.append(rows[i : i + limit]) - i += limit - limit = other - return pages +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( @@ -130,53 +346,23 @@ def export_pdf( ) -> 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 {} - font = _ensure_font() - cols = COLUMNS[table_type] - first = ROWS_FIRST[table_type] - other = ROWS_OTHER[table_type] - pages = _chunk_rows(rows, first, other) - total = max(len(pages), 1) + pages = _paginate_rows(rows, layout.rows_first, layout.rows_other) + total = _total_pages(len(rows), layout.rows_first, layout.rows_other) buf = BytesIO() - c = canvas.Canvas(buf, pagesize=A4) - w, h = A4 + 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, w, h, page_idx, total, inscriptions, table_type) - - # table area - left = 20 * mm - top = h - 10 * mm - row_h = 6 * mm - header_y = top - 8 * mm - - # column headers - x = left + 2 * mm - c.setFont(font, 7) - for field, width, label in cols: - c.drawString(x, header_y, label) - x += width - c.line(left, header_y - 2 * mm, left + sum(w for _, w, _ in cols) + 4 * mm, header_y - 2 * mm) - - y = header_y - row_h - for row in page_rows: - if y < 65 * mm and page_idx == 1: - break - if y < 25 * mm: - break - x = left + 2 * mm - style_size = 8 if row.get("is_header") else 7 - c.setFont(font, style_size) - for field, width, _ in cols: - text = "" if row.get("is_empty") else str(row.get(field, "") or "") - # truncate to fit roughly - max_chars = max(int(width / mm), 1) - c.drawString(x, y, text[: max_chars + 5]) - x += width - y -= row_h - - c.showPage() + _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()