240 lines
10 KiB
Python
240 lines
10 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자 ·
|
||
금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) ·
|
||
레벨 = 들여쓰기 · 열 폭 · 인쇄 = 시트 방향 · A4 · 폭 1쪽 · 머리 줄 반복
|
||
|
||
`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음
|
||
(같은 머리 · 같은 권한 = 회사 · 프로젝트).
|
||
PDF · 엑셀 인쇄 설정 = 공용 `common_util_print_pdf`(이 파일은 인쇄 CSS · 줄임만).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import html as htmlmod
|
||
import io
|
||
from typing import Any, Literal
|
||
from urllib.parse import quote
|
||
from uuid import UUID
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import Response
|
||
from pydantic import BaseModel, Field
|
||
|
||
from common_util.common_util_print_pdf import ( # noqa: F401 — PrintError · sheet_title = 시험 이음
|
||
PrintError,
|
||
number_format,
|
||
print_setup,
|
||
render_html_to_pdf,
|
||
sheet_title,
|
||
)
|
||
|
||
#: 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; }
|
||
"""
|
||
|
||
|
||
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 spawn_pdf(doc: str) -> bytes:
|
||
"""document_html → PDF(쪽 크기 = 인쇄 CSS · 넓은 표 줄임 · 쪽 번호). 실패 = PrintError."""
|
||
return render_html_to_pdf(
|
||
doc,
|
||
page_size=None,
|
||
page_numbers=True,
|
||
prepare_js=FIT_JS,
|
||
prepare_arg=[PRINT_W["portrait"], PRINT_W["landscape"]],
|
||
)
|
||
|
||
|
||
# ── 엑셀 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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()
|
||
|
||
|
||
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")
|