Files
Aislo/E05_StabReport/E05_StabReport_Print.py
T
eomsangdonandClaude Opus 5.5 9db4b4de33 feat(E05): 보고서 「확률해석」 시트(있을 때만) — 측점 · 비탈 · 조건마다 결정론 Fs · 평균 · σ · Pf · β · P(F<기준) · 변수 토네이도 순 · 양식 s12 · 자리표 (537-1 · 요구 351)
- E05_StabReport_Prob(새 파일): E03 확률 저장본(가장 최근) → s12 · 기본값 후보 그대로면 「사용자 확정 전」 · 아니면 「사용자 입력」
- Build: 입력 · 해석 지문 · 프로그램 몫 s12 · 없으면 시트 뺌 · Print 목차 제목
- 양식 stab_report.json s12(가로 · A~K) · stab_report_slots.md s12 · 양식 시험 시트 차례 · 빈 판 쪽 수 +1
- 시험 test_537_1_e03_prob_route: E05 시트 값 = 저장본 · 없으면 뺌 · 화면 계산 TS 요청 = 서버 ProbRequest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 05:04:56 +09:00

232 lines
9.9 KiB
Python

"""E05 보고서 인쇄 — 목차 · 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔(요구 347-5 · 351 · PLAN 534-5).
fill_toc(sheet, book, pages) 목차 줄(장 · 내용 · 쪽) — 쪽 없으면 「-」
number_toc(book) 시트별 쪽 수를 재서 목차 쪽 번호를 채움(브라우저 한 번)
report_pdf(book, figures) 표지(쪽 번호 없음) + 본문(머리말 사업명 · 꼬리말 n / N) 한 벌
balance_breaks(sheet, book, first, last)
긴 표 쪽 나눔 — 쪽마다 고르게(마지막 쪽에 한두 줄만 남지 않게)
쪽 번호 = 표지를 뺀 본문 차례(목차 = 1 쪽). 쪽 수 = 시트 하나씩 인쇄 HTML → PDF 쪽 수
(시트 경계 = 늘 새 쪽 · `spreadsheet_print_html.ts`). 쪽 재기 · PDF = 공용 PDF 엔진(헤드리스).
"""
from __future__ import annotations
import io
import math
from html import escape
from typing import Any
from common_util.common_util_print_pdf import merge_pdfs, render_htmls_to_pdfs
from common_util.common_util_spreadsheet import print_htmls
from E05_StabReport.E05_StabReport_Cells import A1, _set
COVER, TOC = "s1", "s9"
TOC_FIRST = 4 # 목차 줄 = 4 행부터(4 행 서식)
TITLES = {
"s2": "개요 및 판정 요약",
"s3": "입력 조건 — 해석 방법 · 기준 안전율 · 지하수 · 하중 · 물성치",
"s4": "측점별 안정성 판정 일람표",
"s5": "대표 단면 해석 결과 — 단면도 · 임계 활동면 · 안전율",
"s6": "3D 해석 요약",
"s7": "광역 자연사면 위험 요약",
"s8": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정",
"s11": "암반 비탈면 검토 — 평사투영 · 평면 · 쐐기 안전율",
"s12": "확률 해석 · 민감도 — 파괴확률 · 신뢰도 지수",
"s10": "대표 단면 절편 계산표",
}
APPENDIX = {"s10"}
REPORT_NAME = "비탈면 안정성 검토 보고서"
FONT = "font-family:'Malgun Gothic','맑은 고딕',sans-serif"
PAPER_MM = {"A4": (210, 297), "A3": (297, 420)}
PX_MM = 96 / 25.4
def _row(a1: str) -> int:
return int(A1.match(a1.split(":")[0]).group(2))
def _visible(book: dict[str, Any]) -> list[dict[str, Any]]:
return [s for s in book["시트"] if not s.get("숨김")]
def _start_pages(book: dict[str, Any], pages: dict[str, int]) -> dict[str, int]:
"""시트 id → 본문 시작 쪽(표지 뺌 · 첫 본문 시트 = 1)."""
out, at = {}, 1
for s in _visible(book):
if s["id"] == COVER:
continue
out[s["id"]] = at
at += pages.get(s["id"], 0)
return out
def fill_toc(sheet: dict[str, Any], book: dict[str, Any], pages: dict[str, int] | None) -> None:
"""목차 — 보이는 본문 시트(표지 · 목차 뺌)마다 한 줄 · 장 번호 · 부록 · 시작 쪽."""
tpl = {c: dict(sheet["칸"].get(f"{c}{TOC_FIRST}") or {}) for c in "ABC"}
for a1 in [a for a in sheet["칸"] if _row(a) >= TOC_FIRST]:
cell = sheet["칸"][a1]
if _row(a1) == TOC_FIRST: # 양식 줄 = 서식만 남김
cell.pop("값", None)
else:
del sheet["칸"][a1]
height = (sheet.get("행") or {}).get(str(TOC_FIRST))
start = _start_pages(book, pages) if pages else {}
chapter = 0
for i, s in enumerate(x for x in _visible(book) if x["id"] not in (COVER, TOC)):
r = TOC_FIRST + i
for c, cell in tpl.items():
sheet["칸"][f"{c}{r}"] = {k: v for k, v in cell.items() if k != "값"}
if height:
sheet["행"][str(r)] = dict(height)
if s["id"] in APPENDIX:
mark = "부록"
else:
chapter += 1
mark = f"{chapter}."
_set(sheet, f"A{r}", mark)
_set(sheet, f"B{r}", TITLES.get(s["id"], s["이름"]))
_set(sheet, f"C{r}", start.get(s["id"], "-"))
def _only(book: dict[str, Any], keep: set[str], hide: bool = False) -> dict[str, Any]:
"""`keep` 시트만 보이는(hide = 참이면 `keep` 만 숨긴) 얕은 사본 · 브라우저 계산값 뺌."""
sheets = [{**s, "숨김": True} if (s["id"] in keep) == hide else s for s in book["시트"]]
return {**{k: v for k, v in book.items() if k != "계산값"}, "시트": sheets}
def _figures(book: dict[str, Any], ids: set[str], figures: dict[str, bytes]) -> dict[str, bytes]:
keys = {f["열쇠"] for s in book["시트"] if s["id"] in ids for f in s.get("그림") or []}
return {k: v for k, v in figures.items() if k in keys}
def sheet_pages(book: dict[str, Any]) -> dict[str, int]:
"""보이는 시트마다 인쇄 쪽 수(그림 없이 — 그림 칸 크기는 행열이 정함)."""
from pypdf import PdfReader
ids = [s["id"] for s in _visible(book)]
htmls = print_htmls([{"book": _only(book, {sid})} for sid in ids])
pdfs = render_htmls_to_pdfs([{"html": h, "page_size": None} for h in htmls])
return {sid: len(PdfReader(io.BytesIO(pdf)).pages) for sid, pdf in zip(ids, pdfs)}
def number_toc(book: dict[str, Any]) -> dict[str, int]:
"""목차 시트가 있으면 쪽 수를 재서 쪽 번호를 채움(책을 고침) · 시트별 쪽 수."""
pages = sheet_pages(book)
toc = next((s for s in book["시트"] if s["id"] == TOC and not s.get("숨김")), None)
if toc is not None:
fill_toc(toc, book, pages)
return pages
def _header(project: str) -> str:
return (
f'<div style="width:100%;margin:0 12mm;font-size:8px;{FONT};color:#555;'
"display:flex;justify-content:space-between;border-bottom:0.5px solid #999;"
f'padding-bottom:2px"><span>{escape(project)}</span><span>{REPORT_NAME}</span></div>'
)
FOOTER = (
f'<div style="width:100%;text-align:center;font-size:8px;{FONT};color:#555">'
'- <span class="pageNumber"></span> / <span class="totalPages"></span> -</div>'
)
def report_pdf(book: dict[str, Any], figures: dict[str, bytes]) -> bytes:
"""보고서 PDF — 목차 쪽 번호 = 실제 쪽 · 표지 = 쪽 번호 · 머리말 없음."""
clean = _only(book, set(), hide=True)
clean["시트"] = [
{**s, "칸": {k: dict(v) for k, v in s["칸"].items()}, "행": dict(s.get("행") or {})}
if s["id"] == TOC
else s
for s in clean["시트"]
] # 목차만 고칠 사본
number_toc(clean)
ids = {s["id"] for s in _visible(clean)}
cover = next((s for s in clean["시트"] if s["id"] == COVER), None)
project = str(((cover or {}).get("칸", {}).get("C8") or {}).get("값") or "")
asks, jobs = [], []
if COVER in ids:
asks.append({"book": _only(clean, {COVER}), "figures": _figures(clean, {COVER}, figures)})
jobs.append({"page_size": None})
if ids - {COVER}:
asks.append({"book": _only(clean, {COVER}, hide=True), "figures": figures})
jobs.append(
{"page_size": None, "header_template": _header(project), "footer_template": FOOTER}
)
htmls = print_htmls(asks)
pdfs = render_htmls_to_pdfs([{**job, "html": h} for job, h in zip(jobs, htmls)])
return merge_pdfs(pdfs) if len(pdfs) > 1 else pdfs[0]
# ── 긴 표 쪽 나눔 ────────────────────────────────────────────────────────────
def _col_px(chars: float) -> int:
"""`spreadsheet_headers.colCharsToPx` 와 같은 꼴."""
return round(min(chars, 255) * 7 + 5)
def _page_fit(sheet: dict[str, Any], book: dict[str, Any]) -> tuple[float, Any]:
"""(쪽 하나 높이 px · 배율 반영) · 행 높이 px 함수 — 인쇄 HTML 과 같은 셈."""
pr = sheet.get("인쇄") or {}
w, h = PAPER_MM[pr.get("용지") or "A4"]
if pr.get("방향") == "landscape":
w, h = h, w
m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})}
last_col = max(
(sum((ord(ch) - 64) * 26**i for i, ch in enumerate(reversed(A1.match(a).group(1))))
for a in sheet["칸"]),
default=1,
) # fmt: skip
cols = [_col_name(c) for c in range(1, last_col + 1)]
default_w = (
(sheet.get("기본") or {}).get("열폭") or (book.get("기본") or {}).get("열폭") or 8.43
)
table = sum(_col_px((sheet.get("열") or {}).get(c, {}).get("폭") or default_w) for c in cols)
if (pr.get("맞춤") or {}).get("폭"):
zoom = min(1.0, (w - m["왼"] - m["오른"]) * PX_MM / max(table, 1))
else:
zoom = (pr.get("배율") or 100) / 100
default_pt = (book.get("기본") or {}).get("행높이") or 15
def row_px(r: int) -> float:
return ((sheet.get("행") or {}).get(str(r), {}).get("높이") or default_pt) * 4 / 3
return (h - m["위"] - m["아래"]) * PX_MM / zoom, row_px
def _col_name(n: int) -> str:
out = ""
while n:
n, k = divmod(n - 1, 26)
out = chr(65 + k) + out
return out
def balance_breaks(sheet: dict[str, Any], book: dict[str, Any], first: int, last: int) -> None:
"""`first`~`last` 행 표를 쪽마다 고르게 나눔(쪽나눔 = 덩이 끝 행) — 한 쪽이면 그대로.
ponytail: 한 줄 글 행만 맞음(줄바꿈으로 키 큰 행은 셈 밖) · 어긋남 대비 한 줄 덜 담음.
"""
if last < first:
return
page, row_px = _page_fit(sheet, book)
head = (sheet.get("인쇄") or {}).get("머리행")
head_px = sum(row_px(r) for r in range(head[0], head[1] + 1)) if head else 0.0
pre_px = sum(row_px(r) for r in range(1, head[0] if head else first))
rows = list(range(first, last + 1))
cap_first = max(1, math.floor((page - pre_px - head_px) / row_px(first)) - 1)
cap_next = max(1, math.floor((page - head_px) / row_px(first)) - 1)
caps = [cap_first]
while sum(caps) < len(rows): # 쪽 수
caps.append(cap_next)
if len(caps) == 1:
return
breaks, at, left = [], 0, len(rows)
for i, cap in enumerate(caps[:-1]):
take = min(cap, math.ceil(left / (len(caps) - i)))
at += take
left -= take
breaks.append(rows[at - 1])
sheet["인쇄"]["쪽나눔"] = breaks