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/E03_StabSection/E03_StabSection_Types.py b/E03_StabSection/E03_StabSection_Types.py
new file mode 100644
index 000000000..adfa50720
--- /dev/null
+++ b/E03_StabSection/E03_StabSection_Types.py
@@ -0,0 +1,124 @@
+"""E 안정성평가 공용 꼴 — 2D(E03) · 3D(E04) 해석이 같은 입력 꼴을 씀.
+
+단위: 단위중량 kN/m³ · 점착력 · 하중 kPa · 내부마찰각 ° · 좌표 m.
+E04 가 이 파일을 그대로 import — 이름 · 열쇠를 바꾸지 않음.
+"""
+
+from typing import Literal, TypedDict
+
+GAMMA_W = 9.81 # 물 단위중량 kN/m³
+
+
+class Material(TypedDict):
+ id: str
+ name: str
+ gamma_t: float # 습윤 단위중량 (지하수위 위)
+ gamma_sat: float # 포화 단위중량 (지하수위 아래)
+ c: float # 유효 점착력
+ phi: float # 유효 내부마찰각
+
+
+class Groundwater(TypedDict, total=False):
+ mode: Literal["none", "ru", "line"]
+ ru: float # mode="ru" — 간극수압비 u = ru · γ·h
+ line: list[list[float]] # mode="line" — 지하수위선 [[x, z], …] (2D) · x 오름차순
+
+
+class Surcharge(TypedDict):
+ x0: float
+ x1: float
+ q: float # 연직 등분포 kPa
+
+
+# 요구서 345 6장 기본값(사용자가 E02 에서 바꿈) — 사전조사 후보의 보수 쪽.
+DEFAULT_MATERIALS: list[Material] = [
+ {
+ "id": "weathered_soil",
+ "name": "풍화토",
+ "gamma_t": 18.0,
+ "gamma_sat": 19.0,
+ "c": 15.0,
+ "phi": 30.0,
+ },
+ {
+ "id": "weathered_rock",
+ "name": "풍화암",
+ "gamma_t": 20.0,
+ "gamma_sat": 21.0,
+ "c": 30.0,
+ "phi": 33.0,
+ },
+ {
+ "id": "soft_rock",
+ "name": "연암",
+ "gamma_t": 24.0,
+ "gamma_sat": 24.5,
+ "c": 150.0,
+ "phi": 35.0,
+ },
+ {
+ "id": "hard_rock",
+ "name": "경암",
+ "gamma_t": 26.0,
+ "gamma_sat": 26.0,
+ "c": 300.0,
+ "phi": 40.0,
+ },
+ {"id": "fill", "name": "성토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 10.0, "phi": 25.0},
+ {
+ "id": "colluvium",
+ "name": "붕적토",
+ "gamma_t": 17.5,
+ "gamma_sat": 18.5,
+ "c": 5.0,
+ "phi": 27.0,
+ },
+]
+
+DEFAULT_ROAD_SURCHARGE_KPA = 10.0 # 노면 하중 기본
+
+
+def material(raw: dict) -> Material:
+ """밖에서 온 물성 한 줄을 꼴에 맞춤 — 빠진 γsat 는 γt · 음수 · 범위 밖은 오류."""
+ gamma_t = float(raw["gamma_t"])
+ gamma_sat = float(raw.get("gamma_sat", gamma_t))
+ c = float(raw["c"])
+ phi = float(raw["phi"])
+ if gamma_t <= 0 or gamma_sat <= 0 or c < 0 or not 0 <= phi < 90:
+ raise ValueError(f"물성 범위 밖: {raw}")
+ return {
+ "id": str(raw["id"]),
+ "name": str(raw.get("name", raw["id"])),
+ "gamma_t": gamma_t,
+ "gamma_sat": gamma_sat,
+ "c": c,
+ "phi": phi,
+ }
+
+
+def groundwater(raw: dict | None) -> Groundwater:
+ """지하수 꼴 맞춤 — 없으면 none · line 은 x 오름차순으로 정렬."""
+ if not raw or raw.get("mode", "none") == "none":
+ return {"mode": "none"}
+ mode = raw["mode"]
+ if mode == "ru":
+ ru = float(raw.get("ru", 0.0))
+ if not 0 <= ru < 1:
+ raise ValueError(f"r_u 범위 밖: {ru}")
+ return {"mode": "ru", "ru": ru}
+ if mode == "line":
+ line = sorted([float(x), float(z)] for x, z in raw["line"])
+ if len(line) < 2:
+ raise ValueError("지하수위선 점이 둘 미만")
+ return {"mode": "line", "line": line}
+ raise ValueError(f"지하수 mode 모름: {mode}")
+
+
+def surcharges(raw: list[dict] | None) -> list[Surcharge]:
+ """상재하중 꼴 맞춤 — x0 < x1 로 정렬 · 길이 0 은 버림."""
+ out: list[Surcharge] = []
+ for item in raw or []:
+ x0, x1 = sorted((float(item["x0"]), float(item["x1"])))
+ if x1 > x0:
+ out.append({"x0": x0, "x1": x1, "q": float(item["q"])})
+ return out
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)