fix table

This commit is contained in:
2026-09-02 15:42:48 +03:00
parent dd235f675c
commit 22c7ab7140
7 changed files with 554 additions and 42 deletions
+7 -3
View File
@@ -31,10 +31,12 @@ from app.services.pdf_export import export_pdf
from app.services.project_service import get_project_full, ingest_zip, project_dir from app.services.project_service import get_project_full, ingest_zip, project_dir
from app.services.project_settings import ( from app.services.project_settings import (
TABLE_SETTINGS_META, TABLE_SETTINGS_META,
get_property_names, get_font_stretch,
get_project_params_map, get_project_params_map,
get_property_names,
get_table_settings, get_table_settings,
resolve_inscriptions, resolve_inscriptions,
save_cell_overflows,
update_table_settings, update_table_settings,
) )
from app.services.table_service import ( from app.services.table_service import (
@@ -328,11 +330,13 @@ def export_document(project_id: int, body: ExportRequest, db: Session = Depends(
) )
if body.format == "pdf": if body.format == "pdf":
try: try:
data = export_pdf(body.table_type, rows, resolved_inscriptions) stretch = get_font_stretch(db, project_id, body.table_type)
result = export_pdf(body.table_type, rows, resolved_inscriptions, font_stretch=stretch)
save_cell_overflows(db, project_id, body.table_type, result.overflows)
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) from e raise HTTPException(400, str(e)) from e
return Response( return Response(
content=data, content=result.data,
media_type="application/pdf", media_type="application/pdf",
headers={ headers={
"Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"' "Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"'
+110 -36
View File
@@ -14,6 +14,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from app.core.config import get_settings from app.core.config import get_settings
from app.services.pdf_gost_frame import draw_gost_frame
_font_registered = False _font_registered = False
FONT_NAME = "GOST_A" FONT_NAME = "GOST_A"
@@ -234,6 +235,80 @@ def _draw_rotated_header(
c.restoreState() 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( def _draw_frame(
c: canvas.Canvas, c: canvas.Canvas,
w: float, w: float,
@@ -241,36 +316,10 @@ def _draw_frame(
page: int, page: int,
total: int, total: int,
inscriptions: dict[int, str], inscriptions: dict[int, str],
doc_type: str, table_type: str,
font: str,
) -> None: ) -> None:
font = _ensure_font() draw_gost_frame(c, w, h, inscriptions, table_type, page, total, font)
margin_left = 20 * mm
margin_right = 5 * mm
margin_top = 5 * mm
margin_bottom = 5 * mm
c.setLineWidth(0.8)
c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom)
block_h = 55 * mm if page == 1 else 15 * mm
block_w = 185 * mm
x0 = w - margin_right - block_w
y0 = margin_bottom
c.setLineWidth(0.5)
c.rect(x0, y0, block_w, block_h)
c.setFont(font, 8)
name = inscriptions.get(1, "")
designation = inscriptions.get(2, "")
org = inscriptions.get(9, "")
title = DOC_TITLES.get(doc_type, "")
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 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 _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str: def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str:
@@ -288,6 +337,9 @@ def _draw_table_page(
layout: TableLayout, layout: TableLayout,
page_rows: list[dict[str, Any] | None], page_rows: list[dict[str, Any] | None],
page_h: float, page_h: float,
font_stretch: int,
global_row_start: int,
overflows: list[dict[str, Any]],
) -> None: ) -> None:
font = _ensure_font() font = _ensure_font()
c.setLineWidth(LINE_WIDTH) c.setLineWidth(LINE_WIDTH)
@@ -324,6 +376,7 @@ def _draw_table_page(
for display_i, row in enumerate(page_rows): for display_i, row in enumerate(page_rows):
cy = table_top - header_h - (display_i + 1) * row_h cy = table_top - header_h - (display_i + 1) * row_h
cx = table_x cx = table_x
global_idx = global_row_start + display_i
for col in layout.columns: for col in layout.columns:
cw = col.width_mm * mm cw = col.width_mm * mm
c.rect(cx, cy, cw, row_h) c.rect(cx, cy, cw, row_h)
@@ -331,10 +384,26 @@ def _draw_table_page(
is_header = bool(row and row.get("is_header")) is_header = bool(row and row.get("is_header"))
underline = bool(row and row.get("is_underline")) underline = bool(row and row.get("is_underline"))
align: Align = col.align align: Align = col.align
if col.field in ("designation", "name"): if col.field in ("designation", "name", "product_code", "document_code", "supplier", "note", "where_used"):
align = "center" if is_header else "left" align = "center" if is_header else "left"
_draw_text_in_rect( stretch = _cell_stretch(row, font_stretch)
c, cx, cy, cw, row_h, text, font, FONT_SIZE, align, underline=underline or is_header 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 cx += cw
@@ -343,7 +412,8 @@ def export_pdf(
table_type: str, table_type: str,
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
inscriptions: dict[int, str] | None = None, inscriptions: dict[int, str] | None = None,
) -> bytes: font_stretch: int = 100,
) -> PdfExportResult:
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: if table_type not in TABLE_LAYOUTS:
@@ -353,16 +423,20 @@ def export_pdf(
inscriptions = inscriptions or {} inscriptions = inscriptions or {}
pages = _paginate_rows(rows, layout.rows_first, layout.rows_other) pages = _paginate_rows(rows, layout.rows_first, layout.rows_other)
total = _total_pages(len(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() buf = BytesIO()
page_w, page_h = layout.page_size page_w, page_h = layout.page_size
c = canvas.Canvas(buf, pagesize=layout.page_size) c = canvas.Canvas(buf, pagesize=layout.page_size)
row_offset = 0
for page_idx, page_rows in enumerate(pages, start=1): for page_idx, page_rows in enumerate(pages, start=1):
_draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type) _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type, font)
_draw_table_page(c, layout, page_rows, page_h) _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): if page_idx < len(pages):
c.showPage() c.showPage()
c.save() c.save()
return buf.getvalue() return PdfExportResult(data=buf.getvalue(), overflows=overflows)
+342
View File
@@ -0,0 +1,342 @@
"""GOST title block / frame drawing (ported from controller/pdfcontroller.cpp)."""
from __future__ import annotations
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
FRAME_LINE = 0.5 * mm
M_LEFT = 20 * mm
M_TOP = 5 * mm
M_RIGHT = 5 * mm
M_BOTTOM = 5 * mm
DOC_TYPES = {
"perechen": 1,
"specification_pcb": 2,
"specification": 3,
"vedomost": 4,
}
PRIMARY_APP_FIELD = {1: 251, 2: 252, 3: 253, 4: 254}
def _ins(inscriptions: dict[int, str], num: int) -> str:
return str(inscriptions.get(num, "") or "")
class Inner:
"""Inner document area in ReportLab coordinates (origin bottom-left)."""
def __init__(self, page_w: float, page_h: float):
self.page_w = page_w
self.page_h = page_h
self.left = M_LEFT
self.bottom = M_BOTTOM
self.right = page_w - M_RIGHT
self.top = page_h - M_TOP
def rect(self, x: float, y: float, w: float, h: float) -> None:
pass
def _set_frame_pen(c: canvas.Canvas) -> None:
c.setLineWidth(FRAME_LINE)
c.setStrokeColorRGB(0, 0, 0)
def _txt(
c: canvas.Canvas,
x: float,
y: float,
w: float,
h: float,
text: str,
font: str,
size: float,
align: str = "center",
valign: str = "center",
) -> None:
if not text:
return
c.setFont(font, size)
tw = c.stringWidth(text, font, size)
if align == "center":
tx = x + (w - tw) / 2
elif align == "right":
tx = x + w - tw - 0.5 * mm
else:
tx = x + 0.5 * mm
if valign == "bottom":
ty = y + 1 * mm
elif valign == "top":
ty = y + h - size * 0.35 - 0.5 * mm
else:
ty = y + (h - size * 0.35) / 2
c.drawString(tx, ty, text)
def _txt_multiline(
c: canvas.Canvas,
x: float,
y: float,
w: float,
h: float,
text: str,
font: str,
size: float,
max_lines: int = 3,
) -> None:
if not text:
return
c.setFont(font, size)
words = text.split()
lines: list[str] = []
current = ""
for word in words:
test = f"{current} {word}".strip()
if c.stringWidth(test, font, size) <= w - mm:
current = test
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
lines = lines[:max_lines]
step = size * 1.2
start_y = y + h - size * 0.5 - mm
for i, line in enumerate(lines):
tw = c.stringWidth(line, font, size)
c.drawString(x + (w - tw) / 2, start_y - i * step, line)
def _draw_inner_border(c: canvas.Canvas, inner: Inner) -> None:
_set_frame_pen(c)
c.rect(inner.left, inner.bottom, inner.right - inner.left, inner.top - inner.bottom)
def _draw_left_binding_strip(c: canvas.Canvas, inner: Inner) -> tuple[float, float, float, float]:
"""Left vertical strip (12×145 mm) at bottom-left of inner area."""
w = 12 * mm
h = 145 * mm
x = inner.left - w
y = inner.bottom
c.rect(x, y, w, h)
# horizontal dividers (from bottom): 25, 35, 25, 25, 35 mm sections
offsets = [25, 60, 85, 110, 145]
for off in offsets:
yy = y + off * mm
c.line(x, yy, inner.left, yy)
c.line(x + w + 5 * mm, y, x + w + 5 * mm, y + h)
return x, y, w, h
def _draw_left_binding_text(c: canvas.Canvas, strip_x: float, strip_y: float, inscriptions: dict[int, str], font: str) -> None:
size = 12
c.saveState()
c.translate(strip_x + 12 * mm, strip_y + 145 * mm)
c.rotate(-90)
labels = [
(0, 25, "Инв. № подл"),
(25, 35, "Подп. и дата"),
(60, 25, "Взам. инв. №"),
(85, 25, "Инв. № дубл"),
(110, 35, "Подп. и дата"),
]
for ox, ow, label in labels:
_txt(c, ox * mm, 0, ow * mm, 5 * mm, label, font, size)
vals = [(0, 25, 19), (25, 35, 20), (60, 25, 21), (85, 25, 22), (110, 35, 23)]
for ox, ow, field in vals:
_txt(c, ox * mm, 5 * mm, ow * mm, 7 * mm, _ins(inscriptions, field), font, size)
c.restoreState()
def _draw_top_left_strip(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, font: str) -> None:
w = 12 * mm
h = 120 * mm
x = inner.left - w
y = inner.top - h
c.rect(x, y, w, h)
c.line(x + w + 5 * mm, y, x + w + 5 * mm, y + h)
c.line(x, y + 60 * mm, inner.left, y + 60 * mm)
c.saveState()
c.translate(x + w, y)
c.rotate(-90)
_txt(c, 0, 0, 60 * mm, 5 * mm, "Справ. №", font, 12)
_txt(c, 60 * mm, 0, 60 * mm, 5 * mm, "Перв. примен.", font, 12)
_txt(c, 0, 5 * mm, 60 * mm, 7 * mm, _ins(inscriptions, 24), font, 12)
_txt(
c,
60 * mm,
5 * mm,
60 * mm,
7 * mm,
_ins(inscriptions, PRIMARY_APP_FIELD.get(doc_type, 25)),
font,
12,
)
c.restoreState()
def _draw_stamp_first(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, page: int, total: int, font: str) -> None:
mw, mh = 185 * mm, 40 * mm
x = inner.right - mw
y = inner.bottom
c.rect(x, y, mw, mh)
# horizontal lines
for dy in (5, 10, 15, 20, 25, 30, 35):
if dy <= 10 or dy >= 20:
c.line(x, y + dy * mm, x + (7 + 10 + 23 + 15 + 10) * mm, y + dy * mm)
else:
c.line(x, y + dy * mm, x + mw, y + dy * mm)
c.line(x + 65 * mm, y, x + 65 * mm, y + mh)
for dx in (7, 17, 40, 55):
c.line(x + dx * mm, y, x + dx * mm, y + (15 * mm if dx == 7 else mh))
c.line(x + mw - 50 * mm, y + 15 * mm, x + mw - 50 * mm, y + mh)
c.line(x + mw - 45 * mm, y + 20 * mm, x + mw - 45 * mm, y + 25 * mm)
c.line(x + mw - 40 * mm, y + 20 * mm, x + mw - 40 * mm, y + 25 * mm)
c.line(x + mw - 35 * mm, y + 15 * mm, x + mw - 35 * mm, y + 25 * mm)
c.line(x + mw - 20 * mm, y + 15 * mm, x + mw - 20 * mm, y + 25 * mm)
c.line(x + mw - 50 * mm, y + 20 * mm, x + mw, y + 20 * mm)
c.line(x + mw - 50 * mm, y + 25 * mm, x + mw, y + 25 * mm)
# idk block — 22 mm directly above main stamp top
ix = x + mw - 120 * mm
iy = y + mh
iw, ih = 120 * mm, 22 * mm
c.line(ix, iy, ix, iy + ih)
c.line(ix + iw, iy, ix + iw, iy + ih)
c.line(ix, iy + ih, ix + iw, iy + ih)
c.line(ix, iy + 14 * mm, ix + iw, iy + 14 * mm)
c.line(ix + 14 * mm, iy, ix + 14 * mm, iy + 14 * mm)
c.line(ix + 67 * mm, iy, ix + 67 * mm, iy + 14 * mm)
_txt(c, ix, iy, 14 * mm, 14 * mm, _ins(inscriptions, 27), font, 12)
_txt(c, ix + 14 * mm, iy, 53 * mm, 14 * mm, _ins(inscriptions, 28), font, 12)
_txt(c, ix + 67 * mm, iy, 53 * mm, 14 * mm, _ins(inscriptions, 29), font, 12)
_txt(c, ix, iy + 14 * mm, iw, 8 * mm, _ins(inscriptions, 30), font, 12)
# change block headers
_txt(c, x, y + mh - 5 * mm, 7 * mm, 5 * mm, _ins(inscriptions, 14), font, 12)
_txt(c, x + 7 * mm, y + mh - 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 15), font, 12)
_txt(c, x + 17 * mm, y + mh - 5 * mm, 23 * mm, 5 * mm, _ins(inscriptions, 16), font, 12)
_txt(c, x + 40 * mm, y + mh - 5 * mm, 15 * mm, 5 * mm, _ins(inscriptions, 17), font, 12)
_txt(c, x + 55 * mm, y + mh - 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 18), font, 12)
_txt(c, x, y + mh - 10 * mm, 7 * mm, 5 * mm, "Изм.", font, 12)
_txt(c, x + 7 * mm, y + mh - 10 * mm, 10 * mm, 5 * mm, "Лист", font, 12)
_txt(c, x + 17 * mm, y + mh - 10 * mm, 23 * mm, 5 * mm, "№ докум.", font, 12)
_txt(c, x + 40 * mm, y + mh - 10 * mm, 15 * mm, 5 * mm, "Подп.", font, 12)
_txt(c, x + 55 * mm, y + mh - 10 * mm, 10 * mm, 5 * mm, "Дата", font, 12)
_txt(c, x + 1 * mm, y + mh - 15 * mm, 16 * mm, 5 * mm, "Разраб.", font, 12, "left")
_txt(c, x + 1 * mm, y + mh - 20 * mm, 16 * mm, 5 * mm, "Пров.", font, 12, "left")
_txt(c, x + 1 * mm, y + mh - 25 * mm, 16 * mm, 5 * mm, _ins(inscriptions, 10), font, 12, "left")
_txt(c, x + 18 * mm, y + mh - 25 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 11), font, 12, "left")
_txt(c, x + 18 * mm, y + mh - 15 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 111), font, 12, "left")
_txt(c, x + 18 * mm, y + mh - 20 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 112), font, 12, "left")
_txt(c, x + 18 * mm, y + mh - 30 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 113), font, 12, "left")
_txt(c, x + 18 * mm, y + mh - 35 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 114), font, 12, "left")
_txt(c, x + 41 * mm, y + mh - 25 * mm, 14 * mm, 5 * mm, _ins(inscriptions, 12), font, 12, "left")
_txt(c, x + 56 * mm, y + mh - 25 * mm, 9 * mm, 5 * mm, _ins(inscriptions, 13), font, 12, "left")
_txt(c, x + 1 * mm, y + mh - 30 * mm, 16 * mm, 5 * mm, "Н. контр.", font, 12, "left")
_txt(c, x + 1 * mm, y + mh - 35 * mm, 16 * mm, 5 * mm, "Утв.", font, 12, "left")
# product name / doc title area
if doc_type != 4:
name_text = _ins(inscriptions, 1002 if doc_type == 3 else 1)
_txt_multiline(c, x + 65 * mm, y + mh - 40 * mm, 70 * mm, 25 * mm, name_text, font, 18)
if doc_type == 1:
_txt(c, x + 65 * mm, y + mh - 40 * mm, 70 * mm, 25 * mm, "Перечень элементов", font, 12, valign="bottom")
else:
_txt_multiline(c, x + 65 * mm, y + mh - 35 * mm, 70 * mm, 20 * mm, _ins(inscriptions, 1), font, 18)
_txt(c, x + 65 * mm, y + mh - 35 * mm, 70 * mm, 5 * mm, _ins(inscriptions, 301), font, 12)
_txt(c, x + 65 * mm, y, 70 * mm, 20 * mm, "Ведомость покупных изделий", font, 12, valign="bottom")
_txt(c, x + 65 * mm, y, 70 * mm, 5 * mm, "Копировал", font, 12, "left")
title = _ins(inscriptions, 2)
if doc_type == 1:
title = f"{title} ПЭ3".strip()
elif doc_type == 4:
title = f"{title} ВП".strip()
elif doc_type == 3:
title = _ins(inscriptions, 1001)
_txt(c, x + 65 * mm, y + mh - 15 * mm, 120 * mm, 15 * mm, title, font, 18)
_txt(c, x + 135 * mm, y + mh - 15 * mm, 15 * mm, 5 * mm, "Лит.", font, 12)
_txt(c, x + 150 * mm, y + mh - 15 * mm, 15 * mm, 5 * mm, "Лист", font, 12)
_txt(c, x + 165 * mm, y + mh - 15 * mm, 20 * mm, 5 * mm, "Листов", font, 12)
littera = _ins(inscriptions, 4)
for i in range(min(3, len(littera))):
_txt(c, x + (135 + 5 * i) * mm, y + mh - 20 * mm, 5 * mm, 5 * mm, littera[i], font, 12)
if total > 1:
_txt(c, x + 150 * mm, y + mh - 20 * mm, 15 * mm, 5 * mm, str(page), font, 12)
_txt(c, x + 165 * mm, y + mh - 20 * mm, 20 * mm, 5 * mm, str(total), font, 12)
_txt(c, x + 135 * mm, y + mh - 35 * mm, 50 * mm, 15 * mm, _ins(inscriptions, 9), font, 12)
fmt = "Формат А3" if doc_type == 4 else "Формат А4"
_txt(c, x + 135 * mm, y, 50 * mm, 5 * mm, fmt, font, 12, "left")
def _draw_stamp_other(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, page: int, font: str) -> None:
mw, mh = 185 * mm, 15 * mm
x = inner.right - mw
y = inner.bottom
c.rect(x, y, mw, mh)
for dx in (7, 17, 40, 55, 65, 175):
c.line(x + dx * mm, y, x + dx * mm, y + mh)
c.line(x, y + 5 * mm, x + 65 * mm, y + 5 * mm)
c.line(x, y + 10 * mm, x + 65 * mm, y + 10 * mm)
c.line(x + mw - 10 * mm, y + 7 * mm, x + mw, y + 7 * mm)
_txt(c, x, y, 7 * mm, 5 * mm, "Изм.", font, 12)
_txt(c, x + 7 * mm, y, 10 * mm, 5 * mm, "Лист", font, 12)
_txt(c, x + 17 * mm, y, 23 * mm, 5 * mm, "№ докум", font, 12)
_txt(c, x + 40 * mm, y, 15 * mm, 5 * mm, "Подп.", font, 12)
_txt(c, x + 55 * mm, y, 10 * mm, 5 * mm, "Дата", font, 12)
_txt(c, x, y + 5 * mm, 7 * mm, 5 * mm, _ins(inscriptions, 14), font, 12)
_txt(c, x + 7 * mm, y + 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 15), font, 12)
_txt(c, x + 17 * mm, y + 5 * mm, 23 * mm, 5 * mm, _ins(inscriptions, 16), font, 12)
_txt(c, x + 40 * mm, y + 5 * mm, 15 * mm, 5 * mm, _ins(inscriptions, 17), font, 12)
_txt(c, x + 55 * mm, y + 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 18), font, 12)
title = _ins(inscriptions, 2)
if doc_type == 1:
title = f"{title} ПЭ3".strip()
elif doc_type == 4:
title = f"{title} ВП".strip()
elif doc_type == 3:
title = _ins(inscriptions, 1001)
_txt(c, x + 65 * mm, y + mh - 15 * mm, 110 * mm, 15 * mm, title, font, 24)
_txt(c, x + 65 * mm, y, 110 * mm, 5 * mm, "Копировал", font, 12, "left")
fmt = "Формат А3" if doc_type == 4 else "Формат А4"
_txt(c, x + 65 * mm, y, 110 * mm, 5 * mm, fmt, font, 12, "right")
_txt(c, x + mw - 10 * mm, y + mh - 7 * mm, 10 * mm, 7 * mm, "Лист", font, 12)
_txt(c, x + mw - 10 * mm, y, 10 * mm, 8 * mm, str(page), font, 12)
def draw_gost_frame(
c: canvas.Canvas,
page_w: float,
page_h: float,
inscriptions: dict[int, str],
table_type: str,
page: int,
total: int,
font: str,
) -> None:
"""Draw GOST A4/A3 frame matching desktop layout."""
doc_type = DOC_TYPES.get(table_type, 1)
display_total = total + 1 # desktop increments for registration sheet
inner = Inner(page_w, page_h)
_draw_inner_border(c, inner)
sx, sy, sw, sh = _draw_left_binding_strip(c, inner)
_draw_left_binding_text(c, sx, sy, inscriptions, font)
if page == 1:
_draw_top_left_strip(c, inner, inscriptions, doc_type, font)
_draw_stamp_first(c, inner, inscriptions, doc_type, page, display_total, font)
else:
_draw_stamp_other(c, inner, inscriptions, doc_type, page, font)
@@ -286,6 +286,54 @@ def resolve_where_used(db: Session, project_id: int, mappings: list[str]) -> str
return "" return ""
def get_font_stretch(db: Session, project_id: int, table_type: str) -> int:
doc = DOC_TYPE_KEYS.get(table_type, table_type)
raw = _get_setting(db, project_id, f"fontStretch_{doc}")
if not raw:
return 100
try:
return int(raw)
except ValueError:
return 100
def save_cell_overflows(
db: Session, project_id: int, table_type: str, overflows: list[dict[str, Any]]
) -> None:
merged: dict[str, set[str]] = {}
for item in overflows:
key = str(item.get("row_id") or item.get("row_index"))
merged.setdefault(key, set()).add(item["field"])
payload = {k: sorted(v) for k, v in merged.items()}
_set_setting(db, project_id, f"cell_overflow_{table_type}", json.dumps(payload, ensure_ascii=False))
db.commit()
def get_cell_overflows(db: Session, project_id: int, table_type: str) -> dict[str, list[str]]:
raw = _get_setting(db, project_id, f"cell_overflow_{table_type}")
if not raw:
return {}
try:
data = json.loads(raw)
if isinstance(data, dict):
return {str(k): list(v) for k, v in data.items()}
except json.JSONDecodeError:
pass
return {}
def clear_cell_overflows(db: Session, project_id: int, table_type: str) -> None:
row = db.scalar(
select(ProjectSetting).where(
ProjectSetting.project_id == project_id,
ProjectSetting.key == f"cell_overflow_{table_type}",
)
)
if row:
db.delete(row)
db.commit()
def resolve_decimal_and_board( def resolve_decimal_and_board(
db: Session, project_id: int, project_decimal: str, project_board: str, table_type: str db: Session, project_id: int, project_decimal: str, project_board: str, table_type: str
) -> tuple[str, str]: ) -> tuple[str, str]:
+11 -1
View File
@@ -20,6 +20,8 @@ from app.models import (
VedomostRow, VedomostRow,
) )
from app.services.project_settings import ( from app.services.project_settings import (
clear_cell_overflows,
get_cell_overflows,
get_column_mappings_list, get_column_mappings_list,
get_project_params_map, get_project_params_map,
get_simple_list_settings, get_simple_list_settings,
@@ -123,7 +125,14 @@ def get_rows(db: Session, project_id: int, table_type: str) -> list[dict[str, An
rows = db.scalars( rows = db.scalars(
select(model).where(model.project_id == project_id).order_by(model.row_index) select(model).where(model.project_id == project_id).order_by(model.row_index)
).all() ).all()
return [row_to_dict(r) for r in rows] overflow_map = get_cell_overflows(db, project_id, table_type)
result = []
for r in rows:
data = row_to_dict(r)
key = str(data.get("id") or data.get("row_index"))
data["overflow_fields"] = overflow_map.get(key, [])
result.append(data)
return result
def replace_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: def replace_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -172,6 +181,7 @@ def generate_table(
if table_type not in TABLE_TYPES: if table_type not in TABLE_TYPES:
raise ValueError(f"Unknown table type: {table_type}") raise ValueError(f"Unknown table type: {table_type}")
clear_cell_overflows(db, project.id, table_type)
mappings = _mappings_dict(db, project.id) mappings = _mappings_dict(db, project.id)
components = load_components_for_variant(db, project) components = load_components_for_variant(db, project)
col_mappings = get_column_mappings_list(db, project.id, table_type) col_mappings = get_column_mappings_list(db, project.id, table_type)
+28 -2
View File
@@ -234,6 +234,10 @@ export default function ProjectPage() {
}, [tableType, project, loadTable, loadTableSettings]); }, [tableType, project, loadTable, loadTableSettings]);
const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]); const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]);
const overflowCount = useMemo(
() => rows.reduce((n, r) => n + (r.overflow_fields?.length || 0), 0),
[rows]
);
async function onUpload(e, method = "POST") { async function onUpload(e, method = "POST") {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -620,11 +624,30 @@ export default function ProjectPage() {
Excel Excel
</button> </button>
{tableType !== "simple_list" && ( {tableType !== "simple_list" && (
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "pdf")}> <button
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await api.exportDoc(projectId, tableType, "pdf");
await loadTable();
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
}}
>
PDF PDF
</button> </button>
)} )}
</div> </div>
{overflowCount > 0 && (
<div className="overflow-banner">
Переполнение ячеек: {overflowCount}. Красным отмечены поля, не влезающие в PDF даже с поджимом.
Сократите текст или уменьшите поджим в настройках.
</div>
)}
{tableSettings?.fields?.length > 0 && ( {tableSettings?.fields?.length > 0 && (
<div className="settings-panel stack"> <div className="settings-panel stack">
@@ -714,7 +737,10 @@ export default function ProjectPage() {
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}> <tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
<td className="muted">{row.row_index ?? idx}</td> <td className="muted">{row.row_index ?? idx}</td>
{columns.map(([key]) => ( {columns.map(([key]) => (
<td key={key}> <td
key={key}
className={row.overflow_fields?.includes(key) ? "cell-overflow" : ""}
>
<input <input
value={row[key] ?? ""} value={row[key] ?? ""}
disabled={row.is_empty} disabled={row.is_empty}
+8
View File
@@ -68,6 +68,14 @@ table.data input {
} }
table.data input:focus { border-color: var(--accent); background: #fff; } table.data input:focus { border-color: var(--accent); background: #fff; }
table.data tr.header-row td { font-weight: 700; text-decoration: underline; } table.data tr.header-row td { font-weight: 700; text-decoration: underline; }
table.data td.cell-overflow input { background: #ffd5d5; }
.overflow-banner {
background: #fff0f0;
border: 1px solid #e8a0a0;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; } .tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.tab { .tab {
background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem; background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem;