Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-25 19:25:48 +09:00
3 changed files with 360 additions and 137 deletions
+24 -137
View File
@@ -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
+124
View File
@@ -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
+212
View File
@@ -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 = (
'<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 _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="<span></span>", 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)