"""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'
{escape(project)}{REPORT_NAME}
' ) FOOTER = ( f'
' '- / -
' ) 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