diff --git a/web/backend/app/api/routes.py b/web/backend/app/api/routes.py index a43c6a3..21babe6 100644 --- a/web/backend/app/api/routes.py +++ b/web/backend/app/api/routes.py @@ -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_settings import ( TABLE_SETTINGS_META, - get_property_names, + get_font_stretch, get_project_params_map, + get_property_names, get_table_settings, resolve_inscriptions, + save_cell_overflows, update_table_settings, ) 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": 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: raise HTTPException(400, str(e)) from e return Response( - content=data, + content=result.data, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"' diff --git a/web/backend/app/services/pdf_export.py b/web/backend/app/services/pdf_export.py index ec9ec8a..c5f0eae 100644 --- a/web/backend/app/services/pdf_export.py +++ b/web/backend/app/services/pdf_export.py @@ -14,6 +14,7 @@ from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas from app.core.config import get_settings +from app.services.pdf_gost_frame import draw_gost_frame _font_registered = False FONT_NAME = "GOST_A" @@ -234,6 +235,80 @@ def _draw_rotated_header( 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( c: canvas.Canvas, w: float, @@ -241,36 +316,10 @@ def _draw_frame( page: int, total: int, inscriptions: dict[int, str], - doc_type: str, + table_type: str, + font: str, ) -> None: - font = _ensure_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}") + draw_gost_frame(c, w, h, inscriptions, table_type, page, total, font) def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str: @@ -288,6 +337,9 @@ def _draw_table_page( layout: TableLayout, page_rows: list[dict[str, Any] | None], page_h: float, + font_stretch: int, + global_row_start: int, + overflows: list[dict[str, Any]], ) -> None: font = _ensure_font() c.setLineWidth(LINE_WIDTH) @@ -324,6 +376,7 @@ def _draw_table_page( for display_i, row in enumerate(page_rows): cy = table_top - header_h - (display_i + 1) * row_h cx = table_x + global_idx = global_row_start + display_i for col in layout.columns: cw = col.width_mm * mm c.rect(cx, cy, cw, row_h) @@ -331,10 +384,26 @@ def _draw_table_page( 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"): + if col.field in ("designation", "name", "product_code", "document_code", "supplier", "note", "where_used"): 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 + stretch = _cell_stretch(row, font_stretch) + 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 @@ -343,7 +412,8 @@ def export_pdf( table_type: str, rows: list[dict[str, Any]], inscriptions: dict[int, str] | None = None, -) -> bytes: + font_stretch: int = 100, +) -> PdfExportResult: if table_type == "simple_list": raise ValueError("PDF export is not available for simple_list") if table_type not in TABLE_LAYOUTS: @@ -353,16 +423,20 @@ def export_pdf( inscriptions = inscriptions or {} pages = _paginate_rows(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() page_w, page_h = layout.page_size c = canvas.Canvas(buf, pagesize=layout.page_size) + row_offset = 0 for page_idx, page_rows in enumerate(pages, start=1): - _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type) - _draw_table_page(c, layout, page_rows, page_h) + _draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type, font) + _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): c.showPage() c.save() - return buf.getvalue() + return PdfExportResult(data=buf.getvalue(), overflows=overflows) diff --git a/web/backend/app/services/pdf_gost_frame.py b/web/backend/app/services/pdf_gost_frame.py new file mode 100644 index 0000000..4f2df71 --- /dev/null +++ b/web/backend/app/services/pdf_gost_frame.py @@ -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) diff --git a/web/backend/app/services/project_settings.py b/web/backend/app/services/project_settings.py index 4a6eb2d..847b8ad 100644 --- a/web/backend/app/services/project_settings.py +++ b/web/backend/app/services/project_settings.py @@ -286,6 +286,54 @@ def resolve_where_used(db: Session, project_id: int, mappings: list[str]) -> str 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( db: Session, project_id: int, project_decimal: str, project_board: str, table_type: str ) -> tuple[str, str]: diff --git a/web/backend/app/services/table_service.py b/web/backend/app/services/table_service.py index ec381f3..d3244aa 100644 --- a/web/backend/app/services/table_service.py +++ b/web/backend/app/services/table_service.py @@ -20,6 +20,8 @@ from app.models import ( VedomostRow, ) from app.services.project_settings import ( + clear_cell_overflows, + get_cell_overflows, get_column_mappings_list, get_project_params_map, 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( select(model).where(model.project_id == project_id).order_by(model.row_index) ).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]]: @@ -172,6 +181,7 @@ def generate_table( if table_type not in TABLE_TYPES: raise ValueError(f"Unknown table type: {table_type}") + clear_cell_overflows(db, project.id, table_type) mappings = _mappings_dict(db, project.id) components = load_components_for_variant(db, project) col_mappings = get_column_mappings_list(db, project.id, table_type) diff --git a/web/frontend/src/pages/ProjectPage.jsx b/web/frontend/src/pages/ProjectPage.jsx index bb3b334..bc86127 100644 --- a/web/frontend/src/pages/ProjectPage.jsx +++ b/web/frontend/src/pages/ProjectPage.jsx @@ -234,6 +234,10 @@ export default function ProjectPage() { }, [tableType, project, loadTable, loadTableSettings]); 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") { const file = e.target.files?.[0]; @@ -620,11 +624,30 @@ export default function ProjectPage() { Excel {tableType !== "simple_list" && ( - )} + {overflowCount > 0 && ( +
+ Переполнение ячеек: {overflowCount}. Красным отмечены поля, не влезающие в PDF даже с поджимом. + Сократите текст или уменьшите поджим в настройках. +
+ )} {tableSettings?.fields?.length > 0 && (
@@ -714,7 +737,10 @@ export default function ProjectPage() { {row.row_index ?? idx} {columns.map(([key]) => ( - +