refactor(535-1): PDF 엔진 공용화 — common_util_print_pdf(render_html_to_pdf · render_htmls_to_pdfs · merge_pdfs · 엑셀 인쇄 설정) · B09 표 인쇄는 부르기만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,27 +12,30 @@
|
||||
|
||||
`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음
|
||||
(같은 머리 · 같은 권한 = 회사 · 프로젝트).
|
||||
PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) —
|
||||
서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭).
|
||||
헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만).
|
||||
PDF · 엑셀 인쇄 설정 = 공용 `common_util_print_pdf`(이 파일은 인쇄 CSS · 줄임만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -74,16 +77,6 @@ body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11p
|
||||
.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:
|
||||
@@ -115,105 +108,19 @@ FIT_JS = (
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
"""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"]],
|
||||
)
|
||||
|
||||
|
||||
# ── 엑셀 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 시트들 → 엑셀 바이트(차례 그대로)."""
|
||||
@@ -277,26 +184,6 @@ def build_xlsx(sheets: list[dict[str, Any]]) -> bytes:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user