231 lines
9.9 KiB
Python
231 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": "암반 비탈면 검토 — 평사투영 · 평면 · 쐐기 안전율",
|
|
"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
|