Files
GostGenerator/web/backend/app/services/pdf_export.py
T
2026-09-02 15:42:48 +03:00

443 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)