diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py
index 810d7c93e..87633ad7a 100644
--- a/E05_StabReport/E05_StabReport_Area.py
+++ b/E05_StabReport/E05_StabReport_Area.py
@@ -9,8 +9,10 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
+from E02_StabGround.E02_StabGround_Model import load_defaults
+from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS
-from E07_StabArea.E07_StabArea_Router import RESULT, load
+from E07_StabArea.E07_StabArea_Router import RESULT, AreaRequest, load
LEVEL_NAMES = {"low": "낮음", "mid": "보통", "high": "높음"}
@@ -49,6 +51,35 @@ def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -
sheet["칸"][a1] = cell
+UNCONFIRMED_KEYS = ( # (설정 칸, 글) — 기본값 그대로면 사용자 확정 전
+ ("c_root", "뿌리 점착 {c_root:g} kPa"),
+ ("soil_depth_m", "토층 {soil_depth_m:g} m"),
+ ("tr_lo", "T/R {tr_lo:g}~{tr_hi:g} m"),
+ ("level_lo_pct", "측점 문턱 {level_lo_pct:g} % · {level_hi_pct:g} %"),
+)
+PAIRS = {"tr_lo": "tr_hi", "level_lo_pct": "level_hi_pct"}
+
+
+def _unconfirmed(s: dict[str, Any]) -> str:
+ """기본값 그대로인 설정 · 물성(E02 붕적토 문헌 추정치) — 사용자 확정 전 한 줄."""
+ fields = AreaRequest.model_fields
+ same = [
+ text.format(**s)
+ for key, text in UNCONFIRMED_KEYS
+ if all(
+ k in s and float(s[k]) == float(fields[k].default) for k in (key, PAIRS.get(key, key))
+ )
+ ]
+ base = {m["id"]: m for m in load_defaults()["materials"]}.get(
+ s.get("material_id") or "colluvium"
+ )
+ if base and all(
+ float(s.get(k, -1)) == float(base[k]) for k in ("gamma_t", "gamma_sat", "c", "phi")
+ ):
+ same.insert(0, f"토층 물성 = {base['name']} 문헌 추정치")
+ return f"{' · '.join(same)} — {UNCONFIRMED}" if same else "없음(모두 사용자 입력)"
+
+
def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None:
"""s7 — 조건 넷 · 등급별 면적 여섯 · 측점 등급별 수 · 보통 · 높음 측점(22 행부터 한 줄씩)."""
if not area:
@@ -68,6 +99,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f
)
_set(sheet, "B7", scenario_text(s))
_set(sheet, "B8", str(area["computed_at"]).replace("T", " "))
+ _set(sheet, "B9", _unconfirmed(s))
for k in range(1, 7):
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]]))
lo, hi = s["level_lo_pct"], s["level_hi_pct"]
diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py
index 9529db131..241c49336 100644
--- a/E05_StabReport/E05_StabReport_Build.py
+++ b/E05_StabReport/E05_StabReport_Build.py
@@ -1,15 +1,16 @@
-"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
+"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3 · 534-5).
입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별
-`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) ·
-E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) ·
+`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약
+(있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) ·
E01 복사본(측점 · 횡단 · 노선).
양식 = `resources/master_template/report/stab_report.json`
칸 자리 = 같은 폴더 `stab_report_slots.md`.
몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
-프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8)
-= [보고서 갱신] 때 양식에서 통째로 다시 씀.
+프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 ·
+부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` ·
+목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`.
판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
@@ -20,7 +21,6 @@ storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`
from __future__ import annotations
-import copy
import hashlib
import json
import re
@@ -32,41 +32,37 @@ from common_util.common_util_derived_store import read_derived
from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model
from E03_StabSection import E03_StabSection_View as view2d
-from E03_StabSection.E03_StabSection_Engine_Model import final_surface
from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides
from E04_Stab3D import E04_Stab3D_Run as run3d
from E05_StabReport import E05_StabReport_Area as area
from E05_StabReport import E05_StabReport_Figure as figure
+from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Reinforce as reinforce
+from E05_StabReport import E05_StabReport_Section as section
+from E05_StabReport.E05_StabReport_Cells import ( # noqa: F401 — 시험 · 다른 모듈이 build.* 로 씀
+ A1,
+ SIDE_NAMES,
+ ST_NG,
+ ST_OK,
+ _copy_row,
+ _criterion,
+ _label_at,
+ _labels,
+ _seismic,
+ _set,
+ _verdict,
+ fig_key,
+ mark,
+)
ROOT = Path(__file__).resolve().parents[1]
FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json"
STAGE = "E05_StabReport"
REPORT, META = "report.json", "report_meta.json"
USER_SHEETS = ("s1", "s2")
-PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7", "s8")
-BLOCK = 28 # 대표단면 한 쪽 = 28 행
+PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s10")
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
BAND_KEY = "fig/3d_band.png"
-LOWEST_N = view2d.REPRESENTATIVE_LOWEST
-CASES = ("dry", "wet", "seismic")
-SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
-METHOD_NAMES = {
- "bishop": "Bishop",
- "janbu": "Janbu 보정",
- "janbu_corrected": "Janbu 보정",
- "ordinary": "Fellenius",
- "spencer": "Spencer",
-}
-REASONS = {
- "fill_height": "쌓기 높이 10 m 이상",
- "cut_height": "깎기 높이 15 m 이상",
- "lowest": "최소 안전율 하위",
- "user": "사용자 지정",
-}
-GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
-ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17
-A1 = re.compile(r"^([A-Z]+)(\d+)$")
class MissingInput(Exception):
@@ -86,10 +82,6 @@ def initial_dir(project_root: Path) -> Path:
return Path(project_root) / "initial_snapshot" / STAGE
-def fig_key(station_key: str) -> str:
- return f"fig/{station_key}.png"
-
-
def fig_path(folder: Path, key: str) -> Path:
"""그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`)."""
name = key.removeprefix("fig/")
@@ -108,9 +100,10 @@ def _ground(project_root: Path) -> dict[str, Any]:
)
-def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None:
- saved = read_derived(Path(project_root), KIND_STATION, fp)
- return (saved or {}).get("value", {}).get("out")
+def _station_result(project_root: Path, fp: str) -> tuple[dict[str, Any] | None, list | None]:
+ """측점 저장본 — (결과, 해석에 쓴 넓힌 지반선 · 없으면 None)."""
+ value = (read_derived(Path(project_root), KIND_STATION, fp) or {}).get("value") or {}
+ return value.get("out"), (value.get("inputs") or {}).get("ground")
def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]:
@@ -204,7 +197,7 @@ def gather(project_root: Path) -> dict[str, Any]:
reps = _representatives(r2)
for item in reps:
chainage = float(item["summary"]["chainage_m"])
- item["result"] = _station_result(project_root, item["summary"]["fp"])
+ item["result"], item["ground"] = _station_result(project_root, item["summary"]["fp"])
try:
item["cross"] = source.cross_section(root, chainage)
except KeyError:
@@ -229,63 +222,6 @@ def gather(project_root: Path) -> dict[str, Any]:
# ── 칸 쓰기 ──────────────────────────────────────────────────────────────────
-def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
- """값(None = 지움) · 서식(None = 양식 그대로)."""
- cell = dict(sheet["칸"].get(a1) or {})
- cell.pop("식", None)
- if value is None or value == "":
- cell.pop("값", None)
- else:
- cell["값"] = round(value, 6) if isinstance(value, float) else value
- if style is not None:
- cell["서식"] = style
- if cell:
- sheet["칸"][a1] = cell
- else:
- sheet["칸"].pop(a1, None)
-
-
-def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None:
- for col in cols:
- cell = sheet["칸"].get(f"{col}{src}")
- if cell and dst != src:
- sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {}
-
-
-def _shift(a1: str, dr: int) -> str:
- m = A1.match(a1)
- return f"{m.group(1)}{int(m.group(2)) + dr}"
-
-
-def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None:
- if ok is None:
- _set(sheet, a1, None)
- else:
- _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG)
-
-
-def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float:
- sf = (ground.get("safety_factors") or {}).get(case) or {}
- if side in ("cut", "fill"):
- return float(sf[side])
- return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽
-
-
-def _seismic(ground: dict[str, Any]) -> bool:
- return bool((ground.get("seismic") or {}).get("enabled"))
-
-
-def _labels(stations: list[dict[str, Any]]) -> dict[float, str]:
- return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations}
-
-
-def _label_at(labels: dict[float, str], chainage: float) -> str:
- if not labels:
- return f"{chainage:.2f}"
- near = min(labels, key=lambda c: abs(c - chainage))
- return labels[near] or f"{chainage:.2f}"
-
-
# ── 사용자 몫(처음 한 번) ────────────────────────────────────────────────────
def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
"""표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀."""
@@ -307,9 +243,30 @@ WATER_TEXT = {
}
+def _wet_text(water: dict[str, Any], base: dict[str, Any]) -> str:
+ """우기 지하수 갈래 글(E02 `wet_mode`) · 기본값이면 사용자 확정 전 표기."""
+ mode = water.get("wet_mode") or "kds"
+ if mode == "depth_ratio":
+ ratio = float(water["depth_ratio"])
+ return mark(f"지표 아래 깊이 = 비탈 높이 × {ratio:g}", ratio == float(base["depth_ratio"]))
+ if mode == "ru":
+ ru = float(water["ru"])
+ return mark(f"간극수압비 r_u = {ru:g}", ru == float(base["ru"]))
+ kds = (
+ f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
+ f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}"
+ )
+ if mode == "line":
+ return f"사용자 수위선 {len(water.get('lines') or [])} 구간 · 구간 밖 = {kds} (사용자 입력)"
+ same = all(water.get(k) == base.get(k) for k in ("wet_soil", "wet_rock"))
+ return mark(f"KDS 기본 — {kds}", same)
+
+
def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
"""s3 입력조건."""
ground = inputs["ground"]
+ base = ground_model.load_defaults()
+ base_sf = base["safety_factors"]
_set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교")
_set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로")
_set(
@@ -326,16 +283,14 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
_set(sheet, f"C{r}", "지진 검토 안 함")
continue
sf = ground["safety_factors"][case]
+ sf_base = base_sf.get(case) or {}
+ same = all(float(sf_base.get(k, -1)) == float(sf[k]) for k in ("cut", "fill"))
_set(sheet, f"B{r}", _criterion(ground, case))
- _set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽")
+ text = f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽 · KDS 11 70 05"
+ _set(sheet, f"C{r}", mark(text, same))
water = ground.get("groundwater") or {}
_set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-"))
- _set(
- sheet,
- "B18",
- f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
- f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}",
- )
+ _set(sheet, "B18", _wet_text(water, base["groundwater"]))
seismic = ground.get("seismic") or {}
_set(
sheet,
@@ -344,8 +299,11 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
if _seismic(ground)
else "적용 안 함",
)
- _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0))
- defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]}
+ load = float(ground.get("road_surcharge_kpa") or 0)
+ _set(sheet, "B22", load)
+ default_load = float(base["road_surcharge_kpa"])
+ _set(sheet, "D22", mark("옹벽 배면 상재하중 준용", load == default_load))
+ defaults = {m["id"]: m for m in base["materials"]}
names = {m["id"]: m["name"] for m in ground.get("materials") or []}
mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」
rows: list[tuple[dict[str, Any], str]] = [
@@ -374,7 +332,9 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS
)
_set(
- sheet, f"G{r}", ("문헌 추정치(기본값)" if same else "사용자 입력") if "c" in m else None
+ sheet,
+ f"G{r}",
+ ("문헌 추정치 · 사용자 확정 전" if same else "사용자 입력") if "c" in m else None,
)
@@ -387,7 +347,7 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
ng = 0
for i, st in enumerate(rows):
r = 6 + i
- _copy_row(sheet, 6, r, "ABCDEFGHIJ")
+ _copy_row(sheet, 6, r, "ABCDEFGHIJK")
side = st.get("side")
fs = st.get("fs_by_condition") or {}
_set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"])))
@@ -402,160 +362,10 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
ok = None if verdict not in ("ok", "ng") else verdict == "ok"
ng += ok is False
_verdict(sheet, f"J{r}", ok)
+ _set(sheet, f"K{r}", None if ok is None else "-" if ok else "대책 검토")
return ng
-def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str):
- """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이."""
- best = None
- for case in (result or {}).get("cases") or []:
- if case.get("condition") != condition:
- continue
- if side in ("cut", "fill") and case.get("role") not in (side, None):
- continue
- methods = (case.get("result") or {}).get("methods") or {}
- found = (methods.get(method) or {}).get("min") or next(
- (v["min"] for v in methods.values() if v.get("min")), None
- )
- if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]):
- best = (found, case)
- return best
-
-
-def _block_copy(sheet: dict[str, Any], template: dict[str, Any], k: int) -> None:
- """양식 1~28 행 덩이를 k 번째 자리로(칸 · 병합 · 행 높이 · 조건부서식)."""
- dr = BLOCK * k
- for a1, cell in template["칸"].items():
- if int(A1.match(a1).group(2)) <= BLOCK:
- sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
- for m in template.get("병합", []):
- sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
- for r, info in (template.get("행") or {}).items():
- if int(r) <= BLOCK:
- sheet["행"][str(int(r) + dr)] = dict(info)
- for rule in template.get("조건부서식", []):
- moved = copy.deepcopy(rule)
- moved["id"] = f"{rule['id']}_{k}"
- moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]]
- sheet["조건부서식"].append(moved)
-
-
-CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
-
-
-def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
- """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)]."""
- template = copy.deepcopy(sheet)
- reps = inputs["representatives"]
- ground = inputs["ground"]
- labels = _labels(inputs["stations"])
- sheet["칸"] = {}
- sheet["병합"], sheet["행"], sheet["조건부서식"], sheet["그림"] = [], {}, [], []
- if not reps:
- _block_copy(sheet, template, 0)
- _set(sheet, "A1", "대표 단면 — 해당 없음")
- sheet["인쇄"]["쪽나눔"] = []
- return []
- jobs: list[tuple[str, str]] = []
- for k, rep in enumerate(reps):
- dr = BLOCK * k
- _block_copy(sheet, template, k)
- st = rep["summary"]
- side = st.get("side")
- method = st.get("method") or "bishop"
- label = _label_at(labels, float(st["chainage_m"]))
- picks = {c: _case_slip(rep.get("result"), c, side, method) for c in CASES}
- dry = picks.get("dry")
- height = float(dry[1].get("height_m") or 0) if dry else 0.0
- mark = CIRCLED[k] if k < len(CIRCLED) else str(k + 1)
- _set(
- sheet,
- f"A{1 + dr}",
- f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m",
- )
- reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
- if rep.get("reason") == "lowest":
- reason = f"{reason} {LOWEST_N}"
- _set(sheet, f"B{2 + dr}", reason)
- key = fig_key(rep["station_key"])
- sheet["그림"].append(
- {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"}
- )
- fs = st.get("fs_by_condition") or {}
- slips = []
- for r, case in zip((24, 25, 26), CASES):
- row = r + dr
- if case == "seismic" and not _seismic(ground):
- continue
- value = fs.get(case)
- crit = _criterion(ground, case, side)
- pick = picks.get(case)
- _set(sheet, f"B{row}", METHOD_NAMES.get(method, method))
- _set(sheet, f"C{row}", float(value) if value is not None else None)
- _set(sheet, f"D{row}", crit)
- _verdict(sheet, f"E{row}", None if value is None else float(value) >= crit - 1e-9)
- slip = pick[0] if pick else None
- center = (slip or {}).get("center")
- _set(sheet, f"F{row}", float(slip["radius"]) if slip and slip.get("radius") else None)
- _set(sheet, f"G{row}", float(center[0]) if center else None)
- _set(sheet, f"H{row}", float(center[1]) if center else None)
- if slip and slip.get("points"):
- slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]})
- design = (rep.get("cross") or {}).get("design") or {}
- kind = (dry[0].get("kind") if dry else None) or ""
- layers = rep.get("layers") or {}
- note = [
- {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""),
- f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}",
- f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "",
- ]
- _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n))
- jobs.append((key, _section_figure(rep, slips, f"{label} — {reason}")))
- sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))]
- return jobs
-
-
-def _section_figure(rep: dict[str, Any], slips: list[dict[str, Any]], title: str) -> str:
- cross = rep.get("cross") or {}
- design = cross.get("design") or {}
- ground = sorted(
- (float(s["offset_m"]), float(s["z"]))
- for s in cross.get("samples") or []
- if s.get("valid", True) and s.get("z") is not None
- )
- line = sorted(
- (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
- )
- surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground
- thickness = (rep.get("layers") or {}).get("soil_thickness_m")
- rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None
- edges = design.get("road_edges") or {}
- road = None
- if edges.get("left") and edges.get("right"):
- road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"]))
- heights = []
- if road and line:
- for case in (rep.get("result") or {}).get("cases") or []:
- if case.get("condition") != "dry" or not case.get("height_m"):
- continue
- left = case.get("side") == "left"
- edge = road[1] if left else road[0]
- day = line[-1][0] if left else line[0][0]
- z0, z1 = figure._interp(surface, edge), figure._interp(surface, day)
- if z0 is not None and z1 is not None:
- heights.append({"x": day, "z0": z0, "z1": z1})
- return figure.section_svg(
- title=title,
- ground=ground,
- surface=surface,
- rock=rock,
- slips=slips,
- road=road,
- heights=heights,
- wet=any(s["condition"] == "wet" for s in slips),
- )
-
-
def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
"""s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감."""
ground = inputs["ground"]
@@ -620,14 +430,22 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st
"""양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감."""
by = {s["id"]: s for s in book["시트"]}
fill_conditions(by["s3"], inputs)
+ n = len(inputs["result2d"]["stations"])
fill_stations(by["s4"], inputs)
- jobs = fill_sections(by["s5"], inputs)
+ printer.balance_breaks(by["s4"], book, 6, 5 + n)
+ jobs = section.fill_sections(by["s5"], inputs)
+ section.fill_slices(by["s10"], inputs)
jobs.append(fill_3d(by["s6"], inputs))
area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"]))
+ risky = [
+ r for r in (inputs["area"] or {}).get("stations") or [] if r.get("level") in ("mid", "high")
+ ]
+ printer.balance_breaks(by["s7"], book, 22, 21 + len(risky))
if inputs.get("reinforce"):
reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"]))
else: # 보강 검토가 없으면 시트를 뺌
book["시트"] = [s for s in book["시트"] if s["id"] != "s8"]
+ printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc`
return jobs
diff --git a/E05_StabReport/E05_StabReport_Cells.py b/E05_StabReport/E05_StabReport_Cells.py
new file mode 100644
index 000000000..fedf4c4ec
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Cells.py
@@ -0,0 +1,98 @@
+"""E05 보고서 칸 쓰기 공용 — 이름표 · 서식 번호 · 칸 값 · 판정 · 기준 안전율 · 측점 이름.
+
+(PLAN 534-3 · 534-5) 빌더 · 대표 단면(`_Section`) · 인쇄(`_Print`)가 같이 씀.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+CASES = ("dry", "wet", "seismic")
+SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
+METHOD_NAMES = {
+ "bishop": "Bishop",
+ "janbu": "Janbu 보정",
+ "janbu_corrected": "Janbu 보정",
+ "ordinary": "Fellenius",
+ "spencer": "Spencer",
+}
+REASONS = {
+ "fill_height": "쌓기 높이 10 m 이상",
+ "cut_height": "깎기 높이 15 m 이상",
+ "lowest": "최소 안전율 하위",
+ "user": "사용자 지정",
+}
+GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
+ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17
+A1 = re.compile(r"^([A-Z]+)(\d+)$")
+
+
+def fig_key(station_key: str) -> str:
+ return f"fig/{station_key}.png"
+
+
+UNCONFIRMED = "기본값 · 사용자 확정 전"
+
+
+def mark(text: str, default: bool) -> str:
+ """사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 아니면
+ 「(사용자 입력)」."""
+ return f"{text} ({UNCONFIRMED if default else '사용자 입력'})"
+
+
+def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
+ """값(None = 지움) · 서식(None = 양식 그대로)."""
+ cell = dict(sheet["칸"].get(a1) or {})
+ cell.pop("식", None)
+ if value is None or value == "":
+ cell.pop("값", None)
+ else:
+ cell["값"] = round(value, 6) if isinstance(value, float) else value
+ if style is not None:
+ cell["서식"] = style
+ if cell:
+ sheet["칸"][a1] = cell
+ else:
+ sheet["칸"].pop(a1, None)
+
+
+def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None:
+ for col in cols:
+ cell = sheet["칸"].get(f"{col}{src}")
+ if cell and dst != src:
+ sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {}
+
+
+def _shift(a1: str, dr: int) -> str:
+ m = A1.match(a1)
+ return f"{m.group(1)}{int(m.group(2)) + dr}"
+
+
+def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None:
+ if ok is None:
+ _set(sheet, a1, None)
+ else:
+ _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG)
+
+
+def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float:
+ sf = (ground.get("safety_factors") or {}).get(case) or {}
+ if side in ("cut", "fill"):
+ return float(sf[side])
+ return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽
+
+
+def _seismic(ground: dict[str, Any]) -> bool:
+ return bool((ground.get("seismic") or {}).get("enabled"))
+
+
+def _labels(stations: list[dict[str, Any]]) -> dict[float, str]:
+ return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations}
+
+
+def _label_at(labels: dict[float, str], chainage: float) -> str:
+ if not labels:
+ return f"{chainage:.2f}"
+ near = min(labels, key=lambda c: abs(c - chainage))
+ return labels[near] or f"{chainage:.2f}"
diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py
index 69e4f636c..36816d8d6 100644
--- a/E05_StabReport/E05_StabReport_Figure.py
+++ b/E05_StabReport/E05_StabReport_Figure.py
@@ -1,8 +1,8 @@
"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
- section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 ·
- 우기 지하수위 ·
- 조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금
+ section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) ·
+ 우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 ·
+ 표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례
band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠
좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset
@@ -16,7 +16,7 @@ import math
from html import escape
from typing import Any
-SECTION_W, SECTION_H = 712, 533
+SECTION_W, SECTION_H = 712, 640
BAND_W, BAND_H = 684, 320
OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
@@ -54,6 +54,38 @@ def _interp(line: list[tuple[float, float]], x: float) -> float | None:
return None
+def _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]:
+ """범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄)."""
+ rows: list[list[int]] = [[]]
+ used = 0.0
+ for i, (_, label) in enumerate(items):
+ w = 30 + _text_w(label, size)
+ if rows[-1] and used + w > width:
+ rows.append([])
+ used = 0.0
+ rows[-1].append(i)
+ used += w
+ return rows
+
+
+def _text_w(text: str, size: int) -> float:
+ """글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배."""
+ return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text)
+
+
+PATTERNS = (
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+)
+STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"}
+
+
def section_svg(
*,
title: str,
@@ -64,16 +96,20 @@ def section_svg(
road: tuple[float, float] | None,
heights: list[dict[str, Any]],
wet: bool,
+ strata: list[dict[str, str]] | None = None,
+ fs_box: list[dict[str, Any]] | None = None,
) -> str:
- """대표 단면 SVG.
+ """대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬).
ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
- road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수.
+ road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 ·
+ strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}]
+ 안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음).
"""
- pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64
+ pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88
pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
if not pts:
- return _empty(SECTION_W, SECTION_H, title)
+ return _empty(SECTION_W, SECTION_H, title or "대표 단면")
xs = [p[0] for p in pts]
zs = [p[1] for p in pts]
x_lo, x_hi = min(xs), max(xs)
@@ -94,12 +130,24 @@ def section_svg(
def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [(sx(x), sz(z)) for x, z in line]
+ def area(line: list[tuple[float, float]], y: float) -> str:
+ """선과 y 사이를 닫은 다각형 점 글."""
+ body = screen(line)
+ return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)])
+
+ bottom, top = sz(z_lo), pad_t
out = [
f"")
return "".join(out)
+def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str:
+ """안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정."""
+ width, line = 236, 19
+ height = 24 + line * len(rows)
+ # surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점
+ low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1])
+ x = left + 6 if low_left else right - width - 6
+ y = top + 6
+ out = [
+ f"",
+ f"",
+ ]
+ for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")):
+ out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK))
+ out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK))
+ for k, r in enumerate(rows):
+ yy = y + 22 + line * (k + 1) - 5
+ name = CONDITION_NAMES.get(r["condition"], r["condition"])
+ f = "-" if r.get("F") is None else f"{r['F']:.2f}"
+ verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G"
+ color = INK if r.get("ok") is None else OK if r["ok"] else NG
+ out.append(_text(x + 8, yy, name, 11, "start", fill=INK))
+ out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK))
+ out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK))
+ out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color))
+ return "".join(out)
+
+
def band_svg(
*,
title: str,
diff --git a/E05_StabReport/E05_StabReport_Print.py b/E05_StabReport/E05_StabReport_Print.py
new file mode 100644
index 000000000..2da6e4f76
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Print.py
@@ -0,0 +1,229 @@
+"""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": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정",
+ "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
diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py
index b27f334db..28961e1ec 100644
--- a/E05_StabReport/E05_StabReport_Router.py
+++ b/E05_StabReport/E05_StabReport_Router.py
@@ -29,11 +29,12 @@ from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_print_pdf import PrintError
-from common_util.common_util_spreadsheet import RecalcError, with_server_values, workbook_pdf
+from common_util.common_util_spreadsheet import RecalcError, with_server_values
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
from config.config_db import get_db_pool
from E05_StabReport import E05_StabReport_Build as build
+from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Store as store
router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"])
@@ -192,9 +193,7 @@ def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes:
figures = store.figures_of(root, book)
if kind == "xlsx":
return workbook_to_xlsx(with_server_values(book), figures)
- clean = dict(book)
- clean.pop("계산값", None) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀)
- return workbook_pdf(clean, figures)
+ return printer.report_pdf(book, figures) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀)
async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response:
diff --git a/E05_StabReport/E05_StabReport_Section.py b/E05_StabReport/E05_StabReport_Section.py
new file mode 100644
index 000000000..37ef40bf6
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Section.py
@@ -0,0 +1,318 @@
+"""E05 보고서 s5 대표단면 · s10 부록 절편 — 단면 하나 = 한 쪽(PLAN 534-3 · 534-5).
+
+칸 자리 = `resources/master_template/report/stab_report_slots.md` s5 · s10.
+그림 = `E05_StabReport_Figure.section_svg`(값 셈 없음 · 해석 저장본 옮겨 그림).
+"""
+
+from __future__ import annotations
+
+import copy
+import math
+from typing import Any
+
+from E03_StabSection import E03_StabSection_View as view2d
+from E03_StabSection.E03_StabSection_Engine_Model import final_surface
+from E05_StabReport import E05_StabReport_Figure as figure
+from E05_StabReport.E05_StabReport_Cells import (
+ A1,
+ CASES,
+ GROUND_NAMES,
+ METHOD_NAMES,
+ REASONS,
+ SIDE_NAMES,
+ _criterion,
+ _label_at,
+ _labels,
+ _seismic,
+ _set,
+ _shift,
+ _verdict,
+ fig_key,
+)
+
+BLOCK = 28 # 대표단면 한 쪽 = 28 행
+LOWEST_N = view2d.REPRESENTATIVE_LOWEST
+CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
+CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
+SLICE_COLS = "ABCDEFGHIJKLM"
+SLICE_TPL = 5 # 부록 절편 덩이 꼴 = 양식 1~5 행(제목 · 단위 · 머리 · 줄 · 합계)
+
+
+def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str):
+ """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이."""
+ best = None
+ for case in (result or {}).get("cases") or []:
+ if case.get("condition") != condition:
+ continue
+ if side in ("cut", "fill") and case.get("role") not in (side, None):
+ continue
+ methods = (case.get("result") or {}).get("methods") or {}
+ found = (methods.get(method) or {}).get("min") or next(
+ (v["min"] for v in methods.values() if v.get("min")), None
+ )
+ if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]):
+ best = (found, case)
+ return best
+
+
+def _block_copy(
+ sheet: dict[str, Any], template: dict[str, Any], dr: int, rows: int, tag: str
+) -> None:
+ """양식 1~rows 행 덩이를 dr 행 아래로(칸 · 병합 · 행 높이 · 조건부서식)."""
+ for a1, cell in template["칸"].items():
+ if int(A1.match(a1).group(2)) <= rows:
+ sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
+ for m in template.get("병합", []):
+ if int(A1.match(m.split(":")[0]).group(2)) <= rows:
+ sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
+ for r, info in (template.get("행") or {}).items():
+ if int(r) <= rows:
+ sheet["행"][str(int(r) + dr)] = dict(info)
+ for rule in template.get("조건부서식", []):
+ moved = copy.deepcopy(rule)
+ moved["id"] = f"{rule['id']}_{tag}"
+ moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]]
+ sheet["조건부서식"].append(moved)
+
+
+def _clear(sheet: dict[str, Any]) -> dict[str, Any]:
+ """시트를 비우고 양식 사본을 돌려줌."""
+ template = copy.deepcopy(sheet)
+ sheet["칸"] = {}
+ sheet["병합"], sheet["행"], sheet["조건부서식"] = [], {}, []
+ if "그림" in sheet:
+ sheet["그림"] = []
+ return template
+
+
+def _mark(k: int) -> str:
+ return CIRCLED[k] if k < len(CIRCLED) else str(k + 1)
+
+
+def _picks(rep: dict[str, Any], ground: dict[str, Any]) -> dict[str, Any]:
+ """대표 단면 하나 — 조건별 (면, 경우) · 쓰는 조건 · 지배 조건(Fs / 기준 가장 낮음)."""
+ st = rep["summary"]
+ side, method = st.get("side"), st.get("method") or "bishop"
+ used = [c for c in CASES if c != "seismic" or _seismic(ground)]
+ picks = {c: _case_slip(rep.get("result"), c, side, method) for c in used}
+ fs = st.get("fs_by_condition") or {}
+ ratios = [
+ (float(fs[c]) / _criterion(ground, c, side), c)
+ for c in used
+ if fs.get(c) is not None and picks.get(c)
+ ]
+ governing = min(ratios)[1] if ratios else None
+ return {"side": side, "method": method, "used": used, "picks": picks, "governing": governing}
+
+
+def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
+ """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)]."""
+ reps = inputs["representatives"]
+ ground = inputs["ground"]
+ labels = _labels(inputs["stations"])
+ template = _clear(sheet)
+ if not reps:
+ _block_copy(sheet, template, 0, BLOCK, "0")
+ _set(sheet, "A1", "대표 단면 — 해당 없음")
+ sheet["인쇄"]["쪽나눔"] = []
+ return []
+ jobs: list[tuple[str, str]] = []
+ for k, rep in enumerate(reps):
+ dr = BLOCK * k
+ _block_copy(sheet, template, dr, BLOCK, str(k))
+ st = rep["summary"]
+ got = _picks(rep, ground)
+ side, method, picks = got["side"], got["method"], got["picks"]
+ label = _label_at(labels, float(st["chainage_m"]))
+ dry = picks.get("dry")
+ height = float(dry[1].get("height_m") or 0) if dry else 0.0
+ _set(
+ sheet,
+ f"A{1 + dr}",
+ f"대표 단면 {_mark(k)} — {label} · {SIDE_NAMES.get(side, side or '-')} · "
+ f"H {height:.1f} m",
+ )
+ reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
+ if rep.get("reason") == "lowest":
+ reason = f"{reason} {LOWEST_N}"
+ _set(sheet, f"B{2 + dr}", reason)
+ key = fig_key(rep["station_key"])
+ sheet["그림"].append(
+ {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"}
+ )
+ fs = st.get("fs_by_condition") or {}
+ slips, box = [], []
+ for r, case in zip((24, 25, 26), CASES):
+ row = r + dr
+ if case not in got["used"]:
+ _set(sheet, f"B{row}", "검토 안 함")
+ continue
+ value = fs.get(case)
+ crit = _criterion(ground, case, side)
+ ok = None if value is None else float(value) >= crit - 1e-9
+ pick = picks.get(case)
+ _set(sheet, f"B{row}", METHOD_NAMES.get(method, method))
+ _set(sheet, f"C{row}", float(value) if value is not None else None)
+ _set(sheet, f"D{row}", crit)
+ _verdict(sheet, f"E{row}", ok)
+ slip = pick[0] if pick else None
+ center = (slip or {}).get("center")
+ circle = bool(slip and slip.get("radius") and center)
+ _set(sheet, f"F{row}", float(slip["radius"]) if circle else "-" if slip else None)
+ _set(sheet, f"G{row}", float(center[0]) if circle else "-" if slip else None)
+ _set(sheet, f"H{row}", float(center[1]) if circle else "-" if slip else None)
+ if slip and slip.get("points"):
+ slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]})
+ box.append({"condition": case, "F": value, "required": crit, "ok": ok})
+ design = (rep.get("cross") or {}).get("design") or {}
+ kind = (dry[0].get("kind") if dry else None) or ""
+ layers = rep.get("layers") or {}
+ note = [
+ {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""),
+ f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}",
+ f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "",
+ f"부록 {_mark(k)} 절편 계산표" if got["governing"] else "",
+ ]
+ _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n))
+ jobs.append((key, _section_figure(rep, slips, box, ground)))
+ sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))]
+ return jobs
+
+
+def _strata(rep: dict[str, Any], ground: dict[str, Any], fill: bool) -> list[dict[str, str]]:
+ """지층 범례 — 성토(공사 뒤 지표가 원지반 위인 곳이 있으면) · 위 지층 · 아래 지층."""
+ names = {m["id"]: m for m in ground.get("materials") or []}
+ layers = rep.get("layers") or {}
+ out = []
+ picks = [
+ ("fill", (ground.get("label_map") or {}).get("fill") or "fill", fill),
+ ("soil", layers.get("upper"), True),
+ ("rock", layers.get("lower"), bool(layers.get("soil_thickness_m"))),
+ ]
+ for kind, mid, show in picks:
+ m = names.get(mid)
+ if show and m:
+ text = f"{m['name']} γt {m['gamma_t']:g} · c {m['c']:g} · φ {m['phi']:g}"
+ out.append({"kind": kind, "text": text})
+ return out
+
+
+def _section_figure(
+ rep: dict[str, Any], slips: list[dict[str, Any]], box: list[dict[str, Any]], soil: dict
+) -> str:
+ cross = rep.get("cross") or {}
+ design = cross.get("design") or {}
+ ground = sorted( # 해석에 쓴 넓힌 지반선(임계면이 횡단 표본 밖까지 감) · 없으면 횡단 표본
+ (float(x), float(z)) for x, z in rep.get("ground") or []
+ ) or sorted(
+ (float(s["offset_m"]), float(s["z"]))
+ for s in cross.get("samples") or []
+ if s.get("valid", True) and s.get("z") is not None
+ )
+ line = sorted(
+ (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
+ )
+ surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground
+ thickness = (rep.get("layers") or {}).get("soil_thickness_m")
+ rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None
+ edges = design.get("road_edges") or {}
+ road = None
+ if edges.get("left") and edges.get("right"):
+ road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"]))
+ heights = []
+ if road and line:
+ for case in (rep.get("result") or {}).get("cases") or []:
+ if case.get("condition") != "dry" or not case.get("height_m"):
+ continue
+ left = case.get("side") == "left"
+ edge = road[1] if left else road[0]
+ day = line[-1][0] if left else line[0][0]
+ z_edge, z1 = figure._interp(surface, edge), figure._interp(surface, day)
+ if z_edge is not None and z1 is not None: # 높이 = 엔진 값(비탈 끝에서 길 쪽으로)
+ h = float(case["height_m"])
+ z0 = z1 + h if z_edge > z1 else z1 - h
+ heights.append({"x": day, "z0": z0, "z1": z1, "label": f"H {h:.2f} m"})
+ filled = any(
+ (z_g := figure._interp(ground, x)) is not None and z - z_g > 0.05 for x, z in surface
+ )
+ return figure.section_svg(
+ title="",
+ ground=ground,
+ surface=surface,
+ rock=rock,
+ slips=slips,
+ road=road,
+ heights=heights,
+ wet=any(s["condition"] == "wet" for s in slips),
+ strata=_strata(rep, soil, filled),
+ fs_box=box,
+ )
+
+
+# ── s10 부록 절편 ────────────────────────────────────────────────────────────
+def fill_slices(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
+ """s10 부록 절편 — 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면 절편표 한 쪽."""
+ ground = inputs["ground"]
+ labels = _labels(inputs["stations"])
+ template = _clear(sheet)
+ data_tpl = {c: dict(template["칸"].get(f"{c}4") or {}) for c in SLICE_COLS}
+ sum_tpl = {c: dict(template["칸"].get(f"{c}5") or {}) for c in SLICE_COLS}
+ data_h = (template.get("행") or {}).get("4")
+ sum_h = (template.get("행") or {}).get("5")
+ dr, breaks = 0, []
+ for k, rep in enumerate(inputs["representatives"]):
+ got = _picks(rep, ground)
+ cond = got["governing"]
+ slip = got["picks"][cond][0] if cond else None
+ rows = (slip or {}).get("slices") or []
+ if not rows:
+ continue
+ _block_copy(sheet, template, dr, 3, str(k))
+ label = _label_at(labels, float(rep["summary"]["chainage_m"]))
+ F = float(slip["F"])
+ _set(
+ sheet,
+ f"A{1 + dr}",
+ f"부록 {_mark(k)} 절편 계산표 — {label} · {SIDE_NAMES.get(got['side'], '-')} · "
+ f"{CONDITION_NAMES[cond]} · {METHOD_NAMES.get(got['method'], got['method'])} · "
+ f"Fs {F:.3f}",
+ )
+ kind = {"circle": "원호", "polyline": "비원호"}.get(slip.get("kind"), "")
+ _set(
+ sheet,
+ f"A{2 + dr}",
+ f"대표 단면 {_mark(k)} 의 {CONDITION_NAMES[cond]} 임계 활동면({kind}) · "
+ f"{template['칸']['A2']['값']}",
+ )
+ sums = [0.0, 0.0, 0.0, 0.0]
+ for i, s in enumerate(rows):
+ r = 4 + dr + i
+ for c, cell in data_tpl.items():
+ sheet["칸"][f"{c}{r}"] = dict(cell)
+ if data_h:
+ sheet["행"][str(r)] = dict(data_h)
+ drive = (s["weight"] + s["surcharge"]) * math.sin(math.radians(s["alpha_deg"]))
+ resist = s["shear_mobilized"] * F
+ values = (
+ i + 1, s["width"], s["height"], s["alpha_deg"], s["weight"], s["surcharge"],
+ s["pore_pressure"], s["base_length"], s["c"], s["phi_deg"], s["normal_eff"],
+ drive, resist,
+ ) # fmt: skip
+ for c, v in zip(SLICE_COLS, values):
+ _set(sheet, f"{c}{r}", float(v) if c != "A" else v)
+ for j, v in enumerate((s["weight"], s["surcharge"], drive, resist)):
+ sums[j] += v
+ r = 4 + dr + len(rows)
+ for c, cell in sum_tpl.items():
+ sheet["칸"][f"{c}{r}"] = dict(cell)
+ if sum_h:
+ sheet["행"][str(r)] = dict(sum_h)
+ _set(sheet, f"A{r}", "합계")
+ for c, v in zip("EFLM", sums):
+ _set(sheet, f"{c}{r}", v)
+ dr = r
+ breaks.append(r)
+ if not breaks:
+ _block_copy(sheet, template, 0, 2, "0")
+ _set(sheet, "A1", "부록 — 절편 계산표 없음(대표 단면 없음)")
+ sheet["인쇄"]["쪽나눔"] = breaks
diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py
index 8579210ed..d234d8959 100644
--- a/E05_StabReport/E05_StabReport_Store.py
+++ b/E05_StabReport/E05_StabReport_Store.py
@@ -19,8 +19,10 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
+from common_util.common_util_print_pdf import PrintError
from common_util.common_util_spreadsheet import with_server_values
from common_util.common_util_svg_png import svgs_to_pngs
+from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport.E05_StabReport_Build import (
META,
PROGRAM_SHEETS,
@@ -64,6 +66,15 @@ def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> No
atomic_write_json(folder / META, meta)
+def _numbered(book: dict[str, Any]) -> dict[str, Any]:
+ """목차 쪽 번호를 실제 쪽 수로(브라우저 없으면 「-」 그대로 · PDF 내보내기 때 다시 셈)."""
+ try:
+ printer.number_toc(book)
+ except PrintError:
+ pass
+ return book
+
+
def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]:
return {
"fingerprint": inputs["fingerprint"],
@@ -79,7 +90,7 @@ def create(project_root: Path) -> dict[str, Any]:
book = form()
fill_cover(next(s for s in book["시트"] if s["id"] == "s1"), inputs)
jobs = build_program(book, inputs)
- book = with_server_values(book)
+ book = _numbered(with_server_values(book))
folder = stage_dir(project_root)
figures = _write_figures(folder, jobs)
meta = _meta(book, inputs, figures)
@@ -94,6 +105,22 @@ def create(project_root: Path) -> dict[str, Any]:
return meta
+def _merge_styles(have: list[dict], form_styles: list[dict], sheets: Any) -> list[dict]:
+ """새 판 시트의 서식 번호(양식 기준)를 작업본 서식 표 번호로 — 없는 서식은 끝에 붙임
+ (옛 작업본 · 사용자가 더한 서식과 번호가 엇갈리지 않게)."""
+ styles = list(have)
+ remap: dict[int, int] = {}
+ for i, style in enumerate(form_styles):
+ if style not in styles:
+ styles.append(style)
+ remap[i] = styles.index(style)
+ for sheet in sheets:
+ for cell in sheet["칸"].values():
+ if "서식" in cell:
+ cell["서식"] = remap.get(cell["서식"], cell["서식"])
+ return styles
+
+
def refresh(project_root: Path) -> dict[str, Any]:
"""[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로.
덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`."""
@@ -104,6 +131,7 @@ def refresh(project_root: Path) -> dict[str, Any]:
fresh = form()
jobs = build_program(fresh, inputs)
built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS}
+ book = {**book, "서식": _merge_styles(book.get("서식") or [{}], fresh["서식"], built.values())}
signs = (meta or {}).get("signs") or {}
overwritten = [
s["이름"]
@@ -123,10 +151,13 @@ def refresh(project_root: Path) -> dict[str, Any]:
for s in book["시트"]
if s["id"] in built or s["id"] not in PROGRAM_SHEETS
]
- sheets += [
- built[sid] for sid in PROGRAM_SHEETS if sid in built and sid not in have
- ] # 지운 시트는 끝에 다시
- book = with_server_values({**book, "시트": sheets})
+ order = [s["id"] for s in fresh["시트"]]
+ for sid in order: # 없는 프로그램 몫(지움 · 새로 생긴 목차 · 부록) = 양식 차례 자리에
+ if sid in built and sid not in have:
+ before = {x for x in order[: order.index(sid)]}
+ at = max((i + 1 for i, x in enumerate(sheets) if x["id"] in before), default=0)
+ sheets.insert(at, built[sid])
+ book = _numbered(with_server_values({**book, "시트": sheets}))
figures = _write_figures(folder, jobs)
new_meta = _meta(book, inputs, figures)
_write(project_root, book, new_meta)
diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py
index af885cb90..0961e2a5f 100644
--- a/common_util/common_util_print_pdf.py
+++ b/common_util/common_util_print_pdf.py
@@ -58,6 +58,12 @@ def _pdf_options(job: dict[str, Any]) -> dict[str, Any]:
options.update(
display_header_footer=True, header_template="", footer_template=FOOTER
)
+ if job.get("header_template") or job.get("footer_template"): # 머리말 · 꼬리말 HTML(E05)
+ options.update(
+ display_header_footer=True,
+ header_template=job.get("header_template") or "",
+ footer_template=job.get("footer_template") or "",
+ )
return options
@@ -102,7 +108,9 @@ def render_htmls_to_pdfs(jobs: list[dict[str, Any]], timeout_s: float = TIMEOUT_
"""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(줄임 등).
+ prepare_js=None, prepare_arg=None, header_template=None, footer_template=None} —
+ prepare_js = PDF 직전 page.evaluate(줄임 등) · header/footer_template = 쪽마다 머리말 · 꼬리말
+ HTML(Playwright `pageNumber` · `totalPages` 칸 · 주면 page_numbers 대신).
"""
jobs = [{"page_size": "A4", **job} for job in jobs]
with _lock, tempfile.TemporaryDirectory() as tmp:
diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json
index 585ab1a29..8ee581788 100644
--- a/resources/master_template/report/stab_report.json
+++ b/resources/master_template/report/stab_report.json
@@ -293,6 +293,156 @@
"선": "thin"
}
}
+ },
+ {
+ "크기": 8,
+ "굵게": true,
+ "채움": "#DDEBF7",
+ "가로": "center",
+ "세로": "center",
+ "줄바꿈": true,
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 8,
+ "가로": "center",
+ "세로": "center",
+ "형식": "0.00",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 8,
+ "가로": "center",
+ "세로": "center",
+ "형식": "0.0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 8,
+ "가로": "center",
+ "세로": "center",
+ "형식": "0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 11,
+ "가로": "left",
+ "세로": "center",
+ "테두리": {
+ "아래": {
+ "선": "hair"
+ }
+ }
+ },
+ {
+ "크기": 11,
+ "가로": "right",
+ "세로": "center",
+ "형식": "0",
+ "테두리": {
+ "아래": {
+ "선": "hair"
+ }
+ }
+ },
+ {
+ "크기": 11,
+ "굵게": true,
+ "가로": "left",
+ "세로": "center",
+ "테두리": {
+ "아래": {
+ "선": "hair"
+ }
+ }
+ },
+ {
+ "크기": 8,
+ "굵게": true,
+ "채움": "#F2F2F2",
+ "가로": "center",
+ "세로": "center",
+ "형식": "0.00",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 22,
+ "굵게": true,
+ "가로": "center",
+ "세로": "center",
+ "테두리": {
+ "위": {
+ "선": "thick"
+ },
+ "아래": {
+ "선": "thick"
+ }
+ }
}
],
"시트": [
@@ -302,7 +452,7 @@
"칸": {
"B4": {
"값": "비탈면 안정성 검토 보고서",
- "서식": 1
+ "서식": 26
},
"C4": {
"서식": 1
@@ -416,6 +566,10 @@
},
"E15": {
"서식": 2
+ },
+ "B18": {
+ "값": "검토 기준 — 건설공사 비탈면 설계기준(KDS 11 70 05) · 한계평형해석(Bishop 간편법)",
+ "서식": 2
}
},
"병합": [
@@ -427,7 +581,8 @@
"C11:E11",
"C12:E12",
"B14:E14",
- "B15:E15"
+ "B15:E15",
+ "B18:E18"
],
"열": {
"A": {
@@ -451,7 +606,7 @@
},
"행": {
"4": {
- "높이": 60
+ "높이": 72
},
"5": {
"높이": 24
@@ -476,6 +631,9 @@
},
"12": {
"높이": 30
+ },
+ "18": {
+ "높이": 24
}
},
"보기": {
@@ -496,6 +654,82 @@
}
}
},
+ {
+ "id": "s9",
+ "이름": "목차",
+ "칸": {
+ "A1": {
+ "값": "목 차",
+ "서식": 1
+ },
+ "A3": {
+ "값": "장",
+ "서식": 6
+ },
+ "B3": {
+ "값": "내용",
+ "서식": 6
+ },
+ "C3": {
+ "값": "쪽",
+ "서식": 6
+ },
+ "A4": {
+ "서식": 24
+ },
+ "B4": {
+ "서식": 22
+ },
+ "C4": {
+ "서식": 23
+ }
+ },
+ "병합": [
+ "A1:C1"
+ ],
+ "열": {
+ "A": {
+ "폭": 10
+ },
+ "B": {
+ "폭": 64
+ },
+ "C": {
+ "폭": 10
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 48
+ },
+ "2": {
+ "높이": 12
+ },
+ "3": {
+ "높이": 24
+ },
+ "4": {
+ "높이": 26
+ }
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 30,
+ "아래": 15,
+ "왼": 20,
+ "오른": 20
+ }
+ }
+ },
{
"id": "s2",
"이름": "개요",
@@ -1566,6 +1800,9 @@
},
"G26": {
"서식": 5
+ },
+ "D22": {
+ "서식": 5
}
},
"병합": [
@@ -1586,7 +1823,8 @@
"B18:G18",
"B19:G19",
"A21:G21",
- "A24:G24"
+ "A24:G24",
+ "D22:G22"
],
"열": {
"A": {
@@ -1679,7 +1917,7 @@
"이름": "측점표",
"칸": {
"A1": {
- "값": "측점별 안전율",
+ "값": "측점별 안정성 판정 일람표",
"서식": 1
},
"B1": {
@@ -1710,7 +1948,7 @@
"서식": 1
},
"A2": {
- "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G",
+ "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G · N.G 측점 = 대책 검토 필요",
"서식": 2
},
"B2": {
@@ -1841,21 +2079,32 @@
},
"J6": {
"서식": 7
+ },
+ "K4": {
+ "값": "대책 필요",
+ "서식": 6
+ },
+ "K5": {
+ "서식": 6
+ },
+ "K6": {
+ "서식": 7
}
},
"병합": [
- "A1:J1",
- "A2:J2",
+ "A1:K1",
+ "A2:K2",
"A4:A5",
"B4:B5",
"C4:C5",
"D4:F4",
"G4:I4",
- "J4:J5"
+ "J4:J5",
+ "K4:K5"
],
"열": {
"A": {
- "폭": 16
+ "폭": 20
},
"B": {
"폭": 13
@@ -1883,6 +2132,9 @@
},
"J": {
"폭": 9
+ },
+ "K": {
+ "폭": 12
}
},
"행": {
@@ -2175,64 +2427,64 @@
"높이": 22
},
"3": {
- "높이": 20
+ "높이": 24
},
"4": {
- "높이": 20
+ "높이": 24
},
"5": {
- "높이": 20
+ "높이": 24
},
"6": {
- "높이": 20
+ "높이": 24
},
"7": {
- "높이": 20
+ "높이": 24
},
"8": {
- "높이": 20
+ "높이": 24
},
"9": {
- "높이": 20
+ "높이": 24
},
"10": {
- "높이": 20
+ "높이": 24
},
"11": {
- "높이": 20
+ "높이": 24
},
"12": {
- "높이": 20
+ "높이": 24
},
"13": {
- "높이": 20
+ "높이": 24
},
"14": {
- "높이": 20
+ "높이": 24
},
"15": {
- "높이": 20
+ "높이": 24
},
"16": {
- "높이": 20
+ "높이": 24
},
"17": {
- "높이": 20
+ "높이": 24
},
"18": {
- "높이": 20
+ "높이": 24
},
"19": {
- "높이": 20
+ "높이": 24
},
"20": {
- "높이": 20
+ "높이": 24
},
"21": {
- "높이": 20
+ "높이": 24
},
"22": {
- "높이": 20
+ "높이": 24
},
"23": {
"높이": 32
@@ -2998,6 +3250,13 @@
},
"G22": {
"서식": 16
+ },
+ "A9": {
+ "값": "기본값",
+ "서식": 4
+ },
+ "B9": {
+ "서식": 5
}
},
"병합": [
@@ -3008,6 +3267,7 @@
"B6:H6",
"B7:H7",
"B8:H8",
+ "B9:H9",
"A10:H10",
"A20:H20"
],
@@ -3055,6 +3315,9 @@
},
"21": {
"높이": 32
+ },
+ "9": {
+ "높이": 32
}
},
"보기": {
@@ -3423,6 +3686,233 @@
"오른": 10
}
}
+ },
+ {
+ "id": "s10",
+ "이름": "부록_절편",
+ "칸": {
+ "A1": {
+ "값": "부록 ① 절편 계산표",
+ "서식": 3
+ },
+ "A2": {
+ "값": "단위 — 길이 m · 힘 kN/m(폭 1 m) · 압력 · 점착력 kPa · 각 °",
+ "서식": 2
+ },
+ "A3": {
+ "값": "번호",
+ "서식": 18
+ },
+ "A4": {
+ "서식": 21
+ },
+ "A5": {
+ "값": "합계",
+ "서식": 18
+ },
+ "B3": {
+ "값": "폭 b (m)",
+ "서식": 18
+ },
+ "B4": {
+ "서식": 19
+ },
+ "B5": {
+ "서식": 25
+ },
+ "C3": {
+ "값": "높이 h (m)",
+ "서식": 18
+ },
+ "C4": {
+ "서식": 19
+ },
+ "C5": {
+ "서식": 25
+ },
+ "D3": {
+ "값": "저면 경사 α (°)",
+ "서식": 18
+ },
+ "D4": {
+ "서식": 20
+ },
+ "D5": {
+ "서식": 25
+ },
+ "E3": {
+ "값": "무게 W (kN/m)",
+ "서식": 18
+ },
+ "E4": {
+ "서식": 19
+ },
+ "E5": {
+ "서식": 25
+ },
+ "F3": {
+ "값": "상재 Q (kN/m)",
+ "서식": 18
+ },
+ "F4": {
+ "서식": 19
+ },
+ "F5": {
+ "서식": 25
+ },
+ "G3": {
+ "값": "간극수압 u (kPa)",
+ "서식": 18
+ },
+ "G4": {
+ "서식": 19
+ },
+ "G5": {
+ "서식": 25
+ },
+ "H3": {
+ "값": "저면 길이 l (m)",
+ "서식": 18
+ },
+ "H4": {
+ "서식": 19
+ },
+ "H5": {
+ "서식": 25
+ },
+ "I3": {
+ "값": "점착력 c (kPa)",
+ "서식": 18
+ },
+ "I4": {
+ "서식": 19
+ },
+ "I5": {
+ "서식": 25
+ },
+ "J3": {
+ "값": "마찰각 φ (°)",
+ "서식": 18
+ },
+ "J4": {
+ "서식": 20
+ },
+ "J5": {
+ "서식": 25
+ },
+ "K3": {
+ "값": "유효 수직력 N' (kN/m)",
+ "서식": 18
+ },
+ "K4": {
+ "서식": 19
+ },
+ "K5": {
+ "서식": 25
+ },
+ "L3": {
+ "값": "구동력 (W+Q)·sinα (kN/m)",
+ "서식": 18
+ },
+ "L4": {
+ "서식": 19
+ },
+ "L5": {
+ "서식": 25
+ },
+ "M3": {
+ "값": "저항력 S (kN/m)",
+ "서식": 18
+ },
+ "M4": {
+ "서식": 19
+ },
+ "M5": {
+ "서식": 25
+ }
+ },
+ "병합": [
+ "A1:M1",
+ "A2:M2"
+ ],
+ "열": {
+ "A": {
+ "폭": 5
+ },
+ "B": {
+ "폭": 7.6
+ },
+ "C": {
+ "폭": 7.6
+ },
+ "D": {
+ "폭": 7.6
+ },
+ "E": {
+ "폭": 7.6
+ },
+ "F": {
+ "폭": 7.6
+ },
+ "G": {
+ "폭": 7.6
+ },
+ "H": {
+ "폭": 7.6
+ },
+ "I": {
+ "폭": 7.6
+ },
+ "J": {
+ "폭": 7.6
+ },
+ "K": {
+ "폭": 7.6
+ },
+ "L": {
+ "폭": 7.6
+ },
+ "M": {
+ "폭": 7.6
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 24
+ },
+ "2": {
+ "높이": 16
+ },
+ "3": {
+ "높이": 34
+ },
+ "4": {
+ "높이": 12
+ },
+ "5": {
+ "높이": 14
+ }
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 15,
+ "아래": 15,
+ "왼": 10,
+ "오른": 10
+ },
+ "쪽나눔": [
+ 5
+ ]
+ }
}
],
"활성": "s1"
diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md
index b2a94f85f..9a9aadc3c 100644
--- a/resources/master_template/report/stab_report_slots.md
+++ b/resources/master_template/report/stab_report_slots.md
@@ -4,8 +4,11 @@
## 공통
-- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀.
-- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00).
+- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(목차 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 · 부록_절편) = 갱신 때 통째 다시 씀 · 작업본에 없는 프로그램 몫 시트는 양식 차례 자리에 끼움 · 새 서식은 작업본 서식 표 끝에 붙여 번호를 바꿈.
+- 시트 차례: 표지 · 목차 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · (보강검토) · 부록_절편.
+- PDF: 표지 = 쪽 번호 · 머리말 없음 · 본문 = 머리말(왼 사업명 = 표지 `C8` · 오른 보고서 이름) · 꼬리말 「- n / N -」(목차 = 1 쪽).
+- 사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 바꿈 = 「(사용자 입력)」 · 물성 = 「문헌 추정치 · 사용자 확정 전」.
+- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00) · 18 head8 · 19 num8(0.00) · 20 num8(0.0) · 21 int8 · 22 목차 내용 · 23 목차 쪽 · 24 목차 장 · 25 sum8(0.00 · 회색) · 26 표지 제목.
- 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용.
- 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림.
- 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지).
@@ -19,6 +22,16 @@
| C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 |
| B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) |
+## s9 목차 (프로그램 몫)
+
+| 칸 | 내용 |
+| --- | --- |
+| `A1:C1` | 제목(고정 글) |
+| 3 행 | 머리 — 장 · 내용 · 쪽 |
+| 4 행부터 보이는 본문 시트 하나 = 한 줄 | A 장(`1.` … · 부록 = `부록`)(24) · B 내용(22) · C 시작 쪽(23 · 표지 뺀 본문 차례 · 목차 = 1) |
+
+- 쪽 번호 = 시트 하나씩 인쇄 → PDF 쪽 수로 셈(보고서 만들 때 · PDF 내보내기 때 다시) · 브라우저가 없으면 「-」.
+
## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음)
- 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글.
@@ -34,11 +47,12 @@
| | `C12` `C13` `C14` (C:G 병합) | 비고 글 |
| 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 |
| 노면 하중 | `B22` | 수(kPa · 서식 16) |
+| | `D22` (D:G 병합) | 비고 — 사용자 확정 전 표기 |
| 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 |
- 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간).
-## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
+## s4 측점표 (프로그램 몫 · 판정 일람표 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
- 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로.
@@ -50,9 +64,10 @@
| D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 |
| G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 |
| J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 |
+| K | 대책 필요(`대책 검토` · O.K = `-`) | 7 |
- 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음.
-- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀).
+- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔 = 빌더가 쪽마다 고르게(`인쇄.쪽나눔` · 마지막 쪽에 한두 줄만 남지 않게).
## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽)
@@ -62,14 +77,14 @@
| --- | --- | --- |
| 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) |
| 2 | `B2:H2` 병합 | 선정 사유 글 |
-| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
+| 3~22 | 그림 `A3:H22` | 그림 한 장 — 행 24 pt · 그림 칸 712 × 640 px · 안전율 상자 · 지층 범례(무늬) · 축척 막대 · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
| 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y |
| 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) |
| 27 | `B27:H27` 병합 | 비고 글 |
| 28 | 빈 줄 | 쪽 끝 |
- `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김).
-- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움.
+- 지진을 안 쓰면 26 행(덩이 안) B = 「검토 안 함」. 비원호 면 = F · G · H 「-」. 비고 끝 = 「부록 ⓝ 절편 계산표」.
## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복)
@@ -85,6 +100,7 @@
| 칸 | 내용 |
| --- | --- |
| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 |
+| `B9` (B:H 병합) | 기본값 그대로인 설정 · 물성 — 사용자 확정 전 한 줄 |
| `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 |
| `E11`~`G14` | 측점 등급 표 — 낮음 · 보통 · 높음별 SI<1 셀 비율 기준 글(7) · 측점 수(14) |
| 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 |
@@ -100,3 +116,17 @@
| 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 |
- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나.
+
+## s10 부록_절편 (프로그램 몫 · 대표 단면마다 한 쪽)
+
+양식 1~5 행 = 덩이 꼴(1 제목 · 2 단위 · 3 머리 · 4 절편 줄 서식 · 5 합계 서식). 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면의 절편표.
+
+| 덩이 안 행 | 칸 | 내용 |
+| --- | --- | --- |
+| 1 | `A:M` 병합 | 「부록 ⓝ 절편 계산표 — 측점 · 구분 · 조건 · 방법 · Fs」 |
+| 2 | `A:M` 병합 | 임계면 · 단위 글 |
+| 3 | 머리 | 번호 · 폭 b · 높이 h · 저면 경사 α · 무게 W · 상재 Q · 간극수압 u · 저면 길이 l · c · φ · 유효 수직력 N' · 구동력 (W+Q)·sinα · 저항력 S |
+| 4 … | 절편 하나 = 한 줄 | 서식 21(번호) · 20(α · φ) · 19(그 밖) |
+| 끝 | 합계 | A 「합계」 · E ΣW · F ΣQ · L Σ구동력 · M Σ저항력(서식 25) |
+
+- 저항력 S = 동원 전단 × Fs = c·l + N'·tanφ · 덩이 끝마다 쪽 나눔 · 대표 단면 없음 = `A1` 「부록 — 절편 계산표 없음」.
diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py
index 5e3b36f1d..41da1f402 100644
--- a/resources/tester/test_534_3_e05_report.py
+++ b/resources/tester/test_534_3_e05_report.py
@@ -1,8 +1,10 @@
-"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
+"""534-3 · 534-5(빌더 · storage · 길 · 인쇄) — E05 안정성평가 보고서 `E05_StabReport_Build` ·
+`_Section` · `_Print` · `_Store` · `_Router`.
가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 ·
3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 ·
-갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
+갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수 ·
+목차 쪽 번호 = 실제 · 부록 절편 · 측점표 쪽 나눔 고르게 · 옛 작업본 갱신(목차 자리 · 서식 붙임).
"""
import io
@@ -15,11 +17,11 @@ from openpyxl import load_workbook
from common_util.common_util_derived_store import derived_dir, write_derived
from common_util.common_util_print_pdf import PrintError
-from common_util.common_util_spreadsheet import workbook_pdf
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
from E02_StabGround import E02_StabGround_Model as ground_model
from E05_StabReport import E05_StabReport_Build as build
from E05_StabReport import E05_StabReport_Figure as figure
+from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Store as store
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -29,6 +31,7 @@ REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_heigh
# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔
OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False}
KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station"
+SLICES = 24 # 임계면 절편 수
REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
@@ -82,9 +85,24 @@ def _write_summary(root: Path, fp: str, rows: list[dict]) -> None:
write_derived(root, KIND_SUMMARY, fp, value)
+def _slice(i: int) -> dict:
+ return {
+ "width": 0.5, "height": 1.0 + i / 10, "alpha_deg": 10.0 + i, "weight": 9.0 + i,
+ "surcharge": 0.0, "pore_pressure": 2.0, "base_length": 0.51, "c": 15.0,
+ "phi_deg": 30.0, "normal_eff": 8.0 + i, "shear_mobilized": 6.0 + i / 10,
+ } # fmt: skip
+
+
def _slip(F: float) -> dict:
pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)]
- return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5}
+ return {
+ "F": F,
+ "kind": "circle",
+ "points": pts,
+ "center": [0.0, 112.0],
+ "radius": 12.5,
+ "slices": [_slice(i) for i in range(SLICES)],
+ }
def _station_doc(i: int) -> dict:
@@ -208,6 +226,29 @@ def test_처음_만듦_측점수_NG_그림(project):
j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6]
assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT)
assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT)
+ k = [
+ c.get("값")
+ for a1, c in by["측점표"]["칸"].items()
+ if a1.startswith("K") and int(a1[1:]) >= 6
+ ]
+ assert k.count("대책 검토") == len(NG_AT) and k.count("-") == N - len(NG_AT)
+ # 측점표 쪽 나눔 = 고르게(덩이 크기 차 ≤ 1 · 한 줄 꼬리 없음)
+ cuts = by["측점표"]["인쇄"]["쪽나눔"]
+ sizes = [b - a for a, b in zip([5, *cuts], [*cuts, 5 + N])]
+ assert len(sizes) >= 2 and max(sizes) - min(sizes) <= 1
+ # 부록 절편 — 대표 단면마다 지배 조건 한 덩이 · 저항력 = 동원 전단 × Fs · 덩이 끝 쪽 나눔
+ s10 = by["부록_절편"]["칸"]
+ block = 3 + SLICES + 1
+ assert by["부록_절편"]["인쇄"]["쪽나눔"] == [block * (i + 1) for i in range(len(REPS))]
+ assert "건기" in s10["A1"]["값"] and s10["A1"]["값"].startswith("부록 ① 절편 계산표 — No.3")
+ assert s10["A4"]["값"] == 1 and s10["M4"]["값"] == pytest.approx(6.0 * 1.31)
+ assert s10[f"A{block}"]["값"] == "합계"
+ assert s10[f"E{block}"]["값"] == pytest.approx(sum(9.0 + i for i in range(SLICES)))
+ assert book["서식"][s10["B4"]["서식"]]["크기"] == 8
+ # 목차 — 본문 시트 차례 · 쪽 번호(목차 = 1)
+ toc = by["목차"]["칸"]
+ assert [toc[f"B{r}"]["값"] for r in (4, 5)] == [printer.TITLES["s2"], printer.TITLES["s3"]]
+ assert toc["A10"]["값"] == "부록" # 보강검토 없음 → 본문 6 + 부록 and toc["C4"]["값"] == 2
figs = sorted((project / "E05_StabReport" / "fig").glob("*.png"))
assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY
assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
@@ -356,6 +397,29 @@ def test_갱신_사용자몫_불변_덮임(project):
)
+def test_옛_작업본_갱신_목차자리_서식(project):
+ """534-5 전 작업본(목차 · 부록 없음 · 서식 18 개) → 갱신 = 목차는 표지 다음 · 부록은 끝 ·
+ 새 서식 붙임(옛 서식 번호 그대로) · 사용자 몫 불변."""
+ _create(project)
+ book, meta = store.load(project)
+ book["시트"] = [s for s in book["시트"] if s["id"] not in ("s9", "s10")]
+ book["서식"] = book["서식"][:18] + [{"크기": 30}] # 사용자가 더한 서식 = 18 번
+ _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 18}
+ meta["signs"] = {k: v for k, v in meta["signs"].items() if k not in ("s9", "s10")}
+ store.save(project, book)
+ (project / "E05_StabReport" / build.META).write_text(json.dumps(meta), encoding="utf-8")
+ store.refresh(project)
+ book, _ = store.load(project)
+ ids = [s["id"] for s in book["시트"]]
+ assert ids[:3] == ["s1", "s9", "s2"] and ids[-1] == "s10"
+ by = _sheets(book)
+ assert by["표지"]["칸"]["C10"] == {"값": "산림청", "서식": 18} and book["서식"][18] == {
+ "크기": 30
+ }
+ head = book["서식"][by["부록_절편"]["칸"]["A3"]["서식"]]
+ assert head["크기"] == 8 and head["굵게"] and len(book["서식"]) == len(build.form()["서식"]) + 1
+
+
def test_3D_없음_2D_없음(project):
shutil.rmtree(project / "E04_Stab3D")
inputs = build.gather(project)
@@ -375,24 +439,36 @@ def test_xlsx_되읽기_pdf_쪽수(project):
figs = store.figures_of(project, book)
assert len(figs) == len(REPS) + 1
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
- assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"]
+ assert wb.sheetnames == [
+ "표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "부록_절편",
+ ] # fmt: skip
assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1
assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84]
assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith(
"FFC7CE"
)
- book.pop("계산값", None)
try:
- pdf = workbook_pdf(book, figs)
+ pdf = printer.report_pdf(book, figs)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
from pypdf import PdfReader
texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages]
stab = [i for i, t in enumerate(texts) if "산정 안전율" in t]
- assert stab and all("산정 안전율" in texts[i] for i in stab)
- # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1
- assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1
+ assert len(stab) == len(book["시트"][4]["인쇄"]["쪽나눔"]) + 1 # 측점표 = 나눈 덩이 수
+ # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + 부록 3
+ assert len(texts) == 4 + len(stab) + len(REPS) + 1 + 1 + len(REPS)
+ body = len(texts) - 1
+ assert f"/ {body}" not in texts[0] and "검증 임도" not in texts[0].split("보고서")[0]
+ assert all(f"- {i} / {body} -" in texts[i] and "검증 임도" in texts[i] for i in range(1, body))
+ # 목차 쪽 번호 = 실제 쪽(본문 n 쪽 = PDF n + 1 번째 쪽)
+ toc = texts[1]
+ marks = {"측점별 안정성 판정 일람표": "산정 안전율", printer.TITLES["s5"]: "대표 단면 ①"}
+ marks[printer.TITLES["s10"]] = "절편 계산표"
+ for title, inside in marks.items():
+ line = next(x for x in toc.splitlines() if title in x)
+ page = int(line.split()[-1])
+ assert inside in texts[page], (title, page)
def test_단면_그림_SVG():
@@ -430,7 +506,7 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
got = client.get(url).json()
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
- assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7
+ assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 9
book = got["book"]
_sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409
diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py
index 0a6681409..5e983c265 100644
--- a/resources/tester/test_534_3_stab_report_form.py
+++ b/resources/tester/test_534_3_stab_report_form.py
@@ -36,7 +36,10 @@ STYLE_KEYS = {
} # fmt: skip
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
-NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토"]
+NAMES = [
+ "표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토",
+ "부록_절편",
+] # fmt: skip
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -148,7 +151,8 @@ def test_양식_계약():
book = _form()
_contract(book)
assert [s["이름"] for s in book["시트"]] == NAMES
- assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"]
+ ids = ["s1", "s9", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s10"]
+ assert [s["id"] for s in book["시트"]] == ids
def test_양식_인쇄_쪽나눔_머리행_그림():
@@ -161,6 +165,8 @@ def test_양식_인쇄_쪽나눔_머리행_그림():
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
assert figs == {
"표지": 0,
+ "목차": 0,
+ "부록_절편": 0,
"개요": 0,
"입력조건": 0,
"측점표": 0,
@@ -231,8 +237,9 @@ def test_견본_pdf_쪽수_머리행(png):
)
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
assert len(sections) == 2 # 단면마다 한 쪽
- # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1(양식 빈 판)
- assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1
+ # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1 + 부록 1
+ # (양식 빈 판)
+ assert len(pages) == 1 + 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + 1
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
assert html.count("![]()