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 __future__ import annotations
from dataclasses import dataclass
from io import BytesIO from io import BytesIO
from pathlib import Path 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.lib.units import mm
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
@@ -16,6 +17,109 @@ from app.core.config import get_settings
_font_registered = False _font_registered = False
FONT_NAME = "GOST_A" 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: def _ensure_font() -> str:
@@ -30,55 +134,115 @@ def _ensure_font() -> str:
return FONT_NAME if Path(get_settings().font_path).exists() else "Helvetica" return FONT_NAME if Path(get_settings().font_path).exists() else "Helvetica"
DOC_TITLES = { def _total_pages(row_count: int, rows_first: int, rows_other: int) -> int:
"perechen": "Перечень элементов", if row_count <= 0:
"specification_pcb": "Спецификация", return 1
"specification": "Спецификация", pages = 1
"vedomost": "Ведомость покупных изделий", remaining = row_count
} remaining -= rows_first
while remaining > 0:
COLUMNS = { pages += 1
"perechen": [ remaining -= rows_other
("position", 30 * mm, "Поз."), return pages
("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 _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() font = _ensure_font()
margin_left = 20 * mm margin_left = 20 * mm
margin_right = 5 * 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.setLineWidth(0.8)
c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom) 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_h = 55 * mm if page == 1 else 15 * mm
block_w = 185 * mm block_w = 185 * mm
x0 = w - margin_right - block_w 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, "") designation = inscriptions.get(2, "")
org = inscriptions.get(9, "") org = inscriptions.get(9, "")
title = DOC_TITLES.get(doc_type, "") 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 - 6 * mm, title)
c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, designation[:60]) c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, (designation or "")[:80])
c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, name[:60]) c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, (name or "")[:80])
if page == 1: 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 + 2 * mm, y0 + 3 * mm, f"Разраб. {inscriptions.get(111, '')}")
c.drawString(x0 + 50 * mm, y0 + 3 * mm, f"Пров. {inscriptions.get(112, '')}") 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}") 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]]]: def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str:
if not rows: if row is None:
return [[]] return ""
pages: list[list[dict[str, Any]]] = [] if field == "_rownum":
i = 0 return str(display_index + 1)
limit = first if row.get("is_empty"):
while i < len(rows): return ""
pages.append(rows[i : i + limit]) return str(row.get(field, "") or "")
i += limit
limit = other
return pages 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( def export_pdf(
@@ -130,53 +346,23 @@ def export_pdf(
) -> bytes: ) -> bytes:
if table_type == "simple_list": if table_type == "simple_list":
raise ValueError("PDF export is not available for 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 {} inscriptions = inscriptions or {}
font = _ensure_font() pages = _paginate_rows(rows, layout.rows_first, layout.rows_other)
cols = COLUMNS[table_type] total = _total_pages(len(rows), layout.rows_first, layout.rows_other)
first = ROWS_FIRST[table_type]
other = ROWS_OTHER[table_type]
pages = _chunk_rows(rows, first, other)
total = max(len(pages), 1)
buf = BytesIO() buf = BytesIO()
c = canvas.Canvas(buf, pagesize=A4) page_w, page_h = layout.page_size
w, h = A4 c = canvas.Canvas(buf, pagesize=layout.page_size)
for page_idx, page_rows in enumerate(pages, start=1): for page_idx, page_rows in enumerate(pages, start=1):
_draw_frame(c, w, h, page_idx, total, inscriptions, table_type) _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type)
_draw_table_page(c, layout, page_rows, page_h)
# table area if page_idx < len(pages):
left = 20 * mm c.showPage()
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()
c.save() c.save()
return buf.getvalue() return buf.getvalue()