fix table
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user