From 9add13ccbc36b6ea1fa704d109c377d7fdf0925c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:23:42 +0900 Subject: [PATCH] =?UTF-8?q?refactor(535-1):=20PDF=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=ED=99=94=20=E2=80=94=20common=5Futil=5Fprint?= =?UTF-8?q?=5Fpdf(render=5Fhtml=5Fto=5Fpdf=20=C2=B7=20render=5Fhtmls=5Fto?= =?UTF-8?q?=5Fpdfs=20=C2=B7=20merge=5Fpdfs=20=C2=B7=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EC=9D=B8=EC=87=84=20=EC=84=A4=EC=A0=95)=20=C2=B7=20B09=20?= =?UTF-8?q?=ED=91=9C=20=EC=9D=B8=EC=87=84=EB=8A=94=20=EB=B6=80=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../draft/B09_Draft_Print_Router.py | 161 ++----------- common_util/common_util_print_pdf.py | 212 ++++++++++++++++++ 2 files changed, 236 insertions(+), 137 deletions(-) create mode 100644 common_util/common_util_print_pdf.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py index dd7841c95..8b9b73487 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Router.py +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -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 = ( - '
' - ' /
' -) - -_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="", - 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 diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py new file mode 100644 index 000000000..071fbf76b --- /dev/null +++ b/common_util/common_util_print_pdf.py @@ -0,0 +1,212 @@ +"""공용 PDF 엔진 — HTML → PDF(헤드리스 Edge · 없으면 Chrome · Playwright) · PDF 합치기(pypdf) · +엑셀 인쇄 설정. + + render_html_to_pdf(html, *, page_size, landscape, margins, page_numbers) → bytes + render_htmls_to_pdfs([{html, page_size, …}]) → [bytes] 브라우저 한 번으로 여러 벌 + merge_pdfs([bytes]) → bytes + sheet_title · number_format · print_setup 엑셀(openpyxl) 시트 이름 · 숫자 서식 · 인쇄 + +쪽 크기 = page_size — "A4" 등 이름 · (가로 mm, 세로 mm) · None(HTML 의 CSS @page 가 정함). +PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — +서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). +헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +""" + +from __future__ import annotations + +import io +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any + +TIMEOUT_S = 90 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +FOOTER = ( + '
' + ' /
' +) + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 + + +class PrintError(Exception): + """까닭 한 줄(예외 원문 없음).""" + + +def _pdf_options(job: dict[str, Any]) -> dict[str, Any]: + """job → Playwright page.pdf 인자.""" + size = job.get("page_size") + options: dict[str, Any] = {"print_background": True} + if size is None: + options["prefer_css_page_size"] = True + elif isinstance(size, str): + options.update(format=size, landscape=bool(job.get("landscape"))) + else: + width, height = size + if job.get("landscape"): + width, height = max(width, height), min(width, height) + options.update(width=f"{width}mm", height=f"{height}mm") + if job.get("margins"): + options["margin"] = job["margins"] + if job.get("page_numbers"): + options.update( + display_header_footer=True, header_template="", footer_template=FOOTER + ) + return options + + +def _render(jobs: list[dict[str, Any]]) -> list[bytes]: + """자식 프로세스 안 — 브라우저 한 번 띄워 job 마다 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() + out = [] + for job in jobs: + page.set_content(job["html"], wait_until="load") + if job.get("prepare_js"): + page.evaluate(job["prepare_js"], job.get("prepare_arg")) + out.append(page.pdf(**_pdf_options(job))) + return out + finally: + browser.close() + except PrintError: + raise + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise PrintError(NO_BROWSER) from error + + +def render_htmls_to_pdfs(jobs: list[dict[str, Any]], timeout_s: float = TIMEOUT_S) -> list[bytes]: + """HTML 여러 벌 → PDF 여러 벌(차례 그대로 · 자식 프로세스 한 번). + + job = {html, page_size="A4", landscape=False, margins=None, page_numbers=False, + prepare_js=None, prepare_arg=None} — prepare_js = PDF 직전 page.evaluate(줄임 등). + """ + jobs = [{"page_size": "A4", **job} for job in jobs] + with _lock, tempfile.TemporaryDirectory() as tmp: + outs = [str(Path(tmp) / f"{i}.pdf") for i in range(len(jobs))] + try: + done = subprocess.run( + [sys.executable, __file__], + input=json.dumps({"jobs": jobs, "outs": outs}).encode(), + capture_output=True, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired as error: + raise PrintError(f"PDF 만들기가 {timeout_s:.0f}초 안에 안 끝남") 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 [Path(out).read_bytes() for out in outs] + + +def render_html_to_pdf( + html: str, + *, + page_size: str | tuple[float, float] | None = "A4", + landscape: bool = False, + margins: dict[str, str] | None = None, + page_numbers: bool = False, + prepare_js: str | None = None, + prepare_arg: Any = None, + timeout_s: float = TIMEOUT_S, +) -> bytes: + """HTML 한 벌 → PDF 바이트. margins = {"top": "10mm", …}(None = CSS · 0).""" + job = { + "html": html, + "page_size": page_size, + "landscape": landscape, + "margins": margins, + "page_numbers": page_numbers, + "prepare_js": prepare_js, + "prepare_arg": prepare_arg, + } + return render_htmls_to_pdfs([job], timeout_s)[0] + + +def merge_pdfs(parts: list[bytes]) -> bytes: + """PDF 여러 벌 → 한 벌(쪽 차례 그대로 · 같은 글꼴은 한 번만).""" + from pypdf import PdfWriter + + writer = PdfWriter() + for part in parts: + writer.append(io.BytesIO(part)) + writer.compress_identical_objects(remove_identicals=True, remove_orphans=True) + out = io.BytesIO() + writer.write(out) + return out.getvalue() + + +# ── 엑셀 ────────────────────────────────────────────────────────────────── + +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" # 쪽 번호 + + +if __name__ == "__main__": # 자식 프로세스 몸통 + job = json.loads(sys.stdin.buffer.read()) + try: + for out, data in zip(job["outs"], _render(job["jobs"]), strict=True): + Path(out).write_bytes(data) + print(json.dumps({"reason": None})) + except PrintError as error: + print(json.dumps({"reason": str(error)}, ensure_ascii=False)) + sys.exit(0)