369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""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()
|