Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""인쇄 미리보기 표 출력 길(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자 ·
|
||
금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) ·
|
||
레벨 = 들여쓰기 · 열 폭
|
||
|
||
`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 = (
|
||
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
|
||
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
|
||
)
|
||
|
||
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나
|
||
|
||
|
||
class PrintError(Exception):
|
||
"""까닭 한 줄(예외 원문 없음)."""
|
||
|
||
|
||
def _titled(table: str, name: str) -> str:
|
||
"""표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨."""
|
||
span = max(1, table.count("<col "))
|
||
row = f'<tr><th class="sheet-title" colspan="{span}">{htmlmod.escape(name)}</th></tr>'
|
||
return table.replace("<thead>", f"<thead>{row}", 1) if "<thead>" in table else table
|
||
|
||
|
||
def document_html(title: str, sheets: list[dict[str, Any]]) -> str:
|
||
"""시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름."""
|
||
parts = [
|
||
f'<section class="sheet is-{s.get("orientation") or "portrait"}">'
|
||
f"{_titled(s['html'], s['name'])}</section>"
|
||
for s in sheets
|
||
]
|
||
return (
|
||
f'<!doctype html><html><head><meta charset="utf-8"><title>{htmlmod.escape(title)}</title>'
|
||
f"<style>{PRINT_CSS}</style></head><body>{''.join(parts)}</body></html>"
|
||
)
|
||
|
||
|
||
#: 시트 덩이마다 쪽 폭에 맞춰 줄임(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="<span></span>",
|
||
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 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)
|
||
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)
|
||
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")
|