Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Router.py
T

240 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""인쇄 미리보기 표 출력 길(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")