"""인쇄 미리보기 표 출력 길(PLAN 302-1 · 초안) — 서버는 꼴만 바꿈(값 셈 · 저장 없음 · 정본 아님). POST /api/projects/{id}/print/table-pdf {title, sheets:[{name, orientation, html}]} html = 브라우저가 `renderDataTableHtml` 로 만든 표(화면과 같은 꼴) → 인쇄 CSS 를 입혀 헤드리스 Edge(없으면 Chrome)로 A4 PDF 한 벌 · 시트 = 차례대로 새 쪽 · 방향 = 시트마다 · 쪽 머리 = 시트 이름(표 머리와 함께 쪽마다 되풀이) · 쪽 바닥 = 쪽 번호 · 넓은 표 = 쪽 폭에 맞춰 줄임 POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · 레벨 = 들여쓰기 · 열 폭 · 인쇄 = 시트 방향 · A4 · 폭 1쪽 · 머리 줄 반복 `B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 (같은 머리 · 같은 권한 = 회사 · 프로젝트). PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — 서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). 헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). """ from __future__ import annotations import html as htmlmod import io import json import re import subprocess import sys import tempfile import threading from pathlib import Path from typing import Any, Literal TIMEOUT_S = 90 CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" #: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘 PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96} MIN_ZOOM = 0.3 PRINT_CSS = """ @page { size: A4 portrait; margin: 10mm 10mm 14mm; } @page wide { size: A4 landscape; margin: 10mm 10mm 14mm; } html, body { margin: 0; } body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11px; color: #000; } .sheet { break-after: page; } .sheet:last-child { break-after: auto; } .sheet.is-landscape { page: wide; } .ui-data-table__table { width: var(--dt-w); table-layout: fixed; border-collapse: collapse; line-height: 1.3; } .ui-data-table__table caption { display: none; } .ui-data-table__table th, .ui-data-table__table td { height: 20px; padding: 0 4px; overflow: hidden; border: 0.5pt solid #666; white-space: nowrap; white-space-collapse: preserve-spaces; text-overflow: clip; } .ui-data-table__table [data-num] { text-align: right; font-variant-numeric: tabular-nums; } .ui-data-table__table td.is-empty { text-align: center; color: #555; } .ui-data-table__table thead { display: table-header-group; } .ui-data-table__table thead th { background: #eee; font-weight: 600; text-align: center; } .ui-data-table__table thead th.sheet-title { background: #fff; border: 0; text-align: left; font-size: 13px; font-weight: 700; height: 26px; } .ui-data-table__table tfoot { display: table-row-group; } .ui-data-table__table tfoot td { background: #f3f3f3; font-weight: 700; } .ui-data-table__table tbody tr.is-title td { font-weight: 700; } .ui-data-table__table td.is-merged { vertical-align: middle; } .ui-data-table__table tr { break-inside: avoid; } .ui-data-table__table td.ui-data-table__lv { text-align: left; padding-left: calc(4px + var(--dt-lv, 0) * 1em); } .ui-data-table__table td.ui-data-table__lv.is-node { font-weight: 700; } .ui-data-table__guide, .ui-data-table__icon { display: none; } .sheet-figure { margin: 8px 0 0; break-inside: avoid; } .sheet-figure img { max-width: 100%; max-height: 240mm; } .sheet-figure figcaption { font-size: 11px; margin-top: 2px; } """ FOOTER = ( '
' ' /
' ) _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 class PrintError(Exception): """까닭 한 줄(예외 원문 없음).""" def _titled(table: str, name: str) -> str: """표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨.""" span = max(1, table.count("{htmlmod.escape(name)}' return table.replace("", f"{row}", 1) if "" in table else table def document_html(title: str, sheets: list[dict[str, Any]]) -> str: """시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름.""" parts = [ f'
' f"{_titled(s['html'], s['name'])}
" for s in sheets ] return ( f'{htmlmod.escape(title)}' f"{''.join(parts)}" ) #: 시트 덩이마다 쪽 폭에 맞춰 줄임(zoom · 넓은 표만) FIT_JS = ( """(w) => { for (const s of document.querySelectorAll('.sheet')) { const width = s.scrollWidth, max = w[s.classList.contains('is-landscape') ? 1 : 0]; if (width > max) s.style.zoom = String(Math.max(%s, max / width)); } }""" % MIN_ZOOM ) def render_pdf(doc: str) -> bytes: """헤드리스 브라우저 → PDF 바이트. 실패 = PrintError.""" try: from playwright.sync_api import Error as PwError from playwright.sync_api import sync_playwright except ImportError as error: raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error try: with sync_playwright() as p: browser = None for channel in CHANNELS: try: browser = p.chromium.launch(channel=channel, headless=True) break except PwError: continue if browser is None: raise PrintError(NO_BROWSER) try: context = browser.new_context(java_script_enabled=False) context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) page = context.new_page() page.set_content(doc, wait_until="load") page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]]) return page.pdf( prefer_css_page_size=True, print_background=True, display_header_footer=True, header_template="", footer_template=FOOTER, ) finally: browser.close() except PrintError: raise except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 raise PrintError(NO_BROWSER) from error def spawn_pdf(doc: str) -> bytes: """자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음.""" with _lock, tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.pdf" try: done = subprocess.run( [sys.executable, __file__], input=json.dumps({"doc": doc, "out": str(out)}).encode(), capture_output=True, timeout=TIMEOUT_S, ) except subprocess.TimeoutExpired as error: raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error except OSError as error: raise PrintError(NO_BROWSER) from error try: result = json.loads(done.stdout.decode().strip().splitlines()[-1]) except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 result = {"reason": NO_BROWSER} if result.get("reason"): raise PrintError(result["reason"]) return out.read_bytes() # ── 엑셀 ────────────────────────────────────────────────────────────────── BAD_TITLE = re.compile(r"[\[\]:*?/\\]") def sheet_title(name: str, used: set[str]) -> str: """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" title, n = base[:31], 1 while title.lower() in used: n += 1 tail = f" ({n})" title = base[: 31 - len(tail)] + tail used.add(title.lower()) return title def number_format(decimals: int | None) -> str: return "#,##0" + ("." + "0" * decimals if decimals else "") def print_setup(ws: Any, orientation: str, head_rows: int) -> None: """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" from openpyxl.worksheet.page import PageMargins ws.page_setup.orientation = orientation ws.page_setup.paperSize = ws.PAPERSIZE_A4 ws.page_setup.fitToWidth = 1 ws.page_setup.fitToHeight = 0 ws.sheet_properties.pageSetUpPr.fitToPage = True ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) if head_rows: ws.print_title_rows = f"1:{head_rows}" ws.oddHeader.left.text = "&A" # 시트 이름 ws.oddFooter.center.text = "&P / &N" # 쪽 번호 def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: """grid 시트들 → 엑셀 바이트(차례 그대로).""" from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter thin = Side(style="thin", color="666666") box = Border(left=thin, right=thin, top=thin, bottom=thin) head_fill = PatternFill("solid", fgColor="EEEEEE") wb = Workbook() wb.remove(wb.active) used: set[str] = set() for sheet in sheets: ws = wb.create_sheet(sheet_title(sheet["name"], used)) g = sheet["grid"] cols, top = g["cols"], g["head_rows"] for j, col in enumerate(cols, 1): ws.column_dimensions[get_column_letter(j)].width = max(4, round(col["w"] / 7, 1)) for r in range(1, top + 1): for j in range(1, len(cols) + 1): cell = ws.cell(r, j) cell.font, cell.fill, cell.border = Font(bold=True), head_fill, box cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) for h in g["head"]: ws.cell(h["r"] + 1, h["c"] + 1, h["text"]) if h["rs"] > 1 or h["cs"] > 1: ws.merge_cells( start_row=h["r"] + 1, start_column=h["c"] + 1, end_row=h["r"] + h["rs"], end_column=h["c"] + h["cs"], ) lv_col = g.get("level_col") for i, row in enumerate(g["rows"]): r = top + 1 + i bold = bool(g["bold"][i]) if i < len(g["bold"]) else False level = g["levels"][i] if i < len(g["levels"]) else None for j, value in enumerate(row): cell = ws.cell(r, j + 1, value) cell.border = box if bold: cell.font = Font(bold=True) if isinstance(value, (int, float)) and not isinstance(value, bool): cell.number_format = number_format(cols[j].get("dec")) if j == lv_col and level: cell.alignment = Alignment(indent=max(0, int(level) - 1)) for m in g.get("merges", []): ws.merge_cells( start_row=top + 1 + m["r"], start_column=m["c"] + 1, end_row=top + m["r"] + m["rs"], end_column=m["c"] + 1, ) ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center") if top: ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1) print_setup(ws, sheet.get("orientation") or "portrait", top) out = io.BytesIO() wb.save(out) return out.getvalue() if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음 job = json.loads(sys.stdin.buffer.read()) try: Path(job["out"]).write_bytes(render_pdf(job["doc"])) print(json.dumps({"reason": None})) except PrintError as error: print(json.dumps({"reason": str(error)}, ensure_ascii=False)) sys.exit(0) # ── 길 ──────────────────────────────────────────────────────────────────── import asyncio # noqa: E402 from urllib.parse import quote # noqa: E402 from uuid import UUID # noqa: E402 from fastapi import APIRouter, HTTPException # noqa: E402 from fastapi.responses import Response # noqa: E402 from pydantic import BaseModel, Field # noqa: E402 router = APIRouter() MAX_SHEETS = 200 class PdfSheet(BaseModel): name: str = Field(max_length=200) orientation: Literal["portrait", "landscape"] = "portrait" html: str class XlsxSheet(BaseModel): name: str = Field(max_length=200) orientation: Literal["portrait", "landscape"] = "portrait" grid: dict[str, Any] class PdfBody(BaseModel): title: str = Field(default="인쇄", max_length=200) sheets: list[PdfSheet] = Field(min_length=1, max_length=MAX_SHEETS) class XlsxBody(BaseModel): title: str = Field(default="인쇄", max_length=200) sheets: list[XlsxSheet] = Field(min_length=1, max_length=MAX_SHEETS) def _file(data: bytes, media: str, title: str, ext: str) -> Response: name = quote(f"{title}.{ext}") return Response( content=data, media_type=media, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{name}"}, ) @router.post("/{project_id}/print/table-pdf") async def table_pdf(project_id: UUID, body: PdfBody) -> Response: doc = document_html(body.title, [s.model_dump() for s in body.sheets]) try: data = await asyncio.to_thread(spawn_pdf, doc) except PrintError as error: raise HTTPException(status_code=503, detail=str(error)) from error return _file(data, "application/pdf", body.title, "pdf") @router.post("/{project_id}/print/table-xlsx") async def table_xlsx(project_id: UUID, body: XlsxBody) -> Response: try: data = await asyncio.to_thread(build_xlsx, [s.model_dump() for s in body.sheets]) except (KeyError, TypeError, ValueError, IndexError) as error: raise HTTPException(status_code=400, detail=f"표 꼴이 맞지 않음 — {error}") from error media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" return _file(data, media, body.title, "xlsx")