fix table

This commit is contained in:
2026-09-02 15:21:23 +03:00
parent f132e83288
commit dd235f675c
+294 -108
View File
@@ -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()