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 __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()
|
||||||
|
|||||||
Reference in New Issue
Block a user