diff --git a/resources/master_data/scripts/_토목_계수.py b/resources/master_data/scripts/_토목_계수.py
new file mode 100644
index 00000000..18c9965f
--- /dev/null
+++ b/resources/master_data/scripts/_토목_계수.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+"""글 속 계산용 수 → 그룹 계수 표 (build_소요량_건설품셈_토목.py 가 씀).
+
+표에 붙은 주(①…)와 비고 글에서 정규식으로 수를 읽음(손으로 적지 않음). 주 한 줄마다 계수 표 하나.
+"""
+
+import re
+
+from _md표_읽기 import R
+from _토목_표 import conds_out
+
+PCT = r"([\d.]+)%"
+
+
+def clean(line):
+ return re.sub(r"^(비고\s*-?\s*|-\s*|[①-⑳]\s*)", "", line).strip()
+
+
+def rows_of(line):
+ """주 한 줄 → 계수 줄들 [(구분, 값, 단위, 딸린 칸)]."""
+ s = clean(line)
+ out = []
+ m = re.search(r"^(.+?)(?:는|은) (인력품|주재료비|재료비)의 " + PCT, s)
+ if m:
+ out.append((m.group(1), R(m.group(3)), "%", {"기준": m.group(2)}))
+ m2 = re.search(r"(블록 정밀절단\(전동절단기\)에 의한 시공이 아닌 경우) " + PCT, s)
+ if m2:
+ out.append((f"{m.group(1)} — {m2.group(1)}", R(m2.group(2)), "%", {"기준": m.group(2)}))
+ return out
+ m = re.search(
+ r"(시공량|본 품|크레인\(시간\))(?:의|을) " + PCT + r"(?:를|까지| 범위 내에서) ?(감|가산)", s
+ )
+ if m:
+ out.append((s, R(m.group(2)), "%", {"기준": m.group(1), "가감": m.group(3)}))
+ m = re.search(r"(특별인부) (\d+)인을 감", s)
+ if m:
+ out.append((s, R(m.group(2)), "인", {"기준": m.group(1), "가감": "감"}))
+ m = re.search(r"절단품은 본 품의 " + PCT + r", 선단가공\(Beveling\) 품은 본 품의 " + PCT, s)
+ if m:
+ out.append(("금긋기 및 절단품", R(m.group(1)), "%", {"기준": "본 품"}))
+ out.append(("선단가공(Beveling) 품", R(m.group(2)), "%", {"기준": "본 품"}))
+ m = re.search(r"두께 ([\d.]+)㎝일 때 ([\d,]+)㎡당 살수량은 일반적으로 ([\d.]+)ton", s)
+ if m:
+ out.append(("살수량 기준 두께", R(m.group(1)), "㎝", {}))
+ out.append(("살수량 기준 면적", R(m.group(2)), "㎡", {}))
+ out.append(("살수량", R(m.group(3)), "ton", {}))
+ m = re.search(r"보통인부 (\d+)인기준 두께 (\d+)㎝시 ([\d,]+)㎡, 두께 (\d+)㎝시 ([\d,]+)㎡", s)
+ if m:
+ out.append(("모래 부설 일당시공량 기준 인원", R(m.group(1)), "인", {"기준": "보통인부"}))
+ out.append((f"모래 부설 일당시공량 두께 {m.group(2)}㎝", R(m.group(3)), "㎡", {}))
+ out.append((f"모래 부설 일당시공량 두께 {m.group(4)}㎝", R(m.group(5)), "㎡", {}))
+ m = re.search(r"굴착기\(규격 ([\d.]+)㎥, 사용시간 ([\d.]+)hr\)", s)
+ if m:
+ out.append(("크레인 대체 굴착기 규격", R(m.group(1)), "㎥", {}))
+ out.append(("크레인 대체 굴착기 사용시간", R(m.group(2)), "hr", {}))
+ m = re.search(r"호칭압력 ([\d.]+)(㎏/㎠)를 기준", s)
+ if m:
+ out.append(("호칭압력 기준", R(m.group(1)), m.group(2), {}))
+ m = re.search(r"강관장 ([\d.]+)m를 기준", s)
+ if m:
+ out.append(("강관장 기준", R(m.group(1)), "m", {}))
+ return out
+
+
+def lift(chdir, text, out):
+ src_tabs = list(out.tables)
+ for t in src_tabs:
+ ident = t["열쇠"].split()[0]
+ tags = {}
+ for line in t["주"]:
+ rs = rows_of(line)
+ if not rs:
+ continue
+ tag = "비고" if line.startswith("비고") else "주" + re.match(r"[①-⑳]", line).group(0)
+ tags.setdefault(tag, []).extend(rs)
+ for tag, rs in tags.items():
+ cols, rows = {"값": "구분 따름", "단위": "글"}, []
+ for g, v, u, extra in rs:
+ rows.append({"구분": g, "값": v, "단위": u, **extra})
+ for k in extra:
+ cols.setdefault(k, "글")
+ label = f"{t['이름']} 계수 {tag}"
+ key = f"{ident} {label}"
+ if key in out.keys:
+ continue
+ out.keys.add(key)
+ out.tables.append(
+ {
+ "열쇠": key,
+ "이름": label,
+ "기준": "",
+ "출처": f"{t['출처']} {tag}",
+ "그룹": "계수",
+ "조건": conds_out(["구분"], rows),
+ "값칸": cols,
+ "줄": rows,
+ "주": [],
+ }
+ )
diff --git a/resources/master_data/scripts/_토목_손.py b/resources/master_data/scripts/_토목_손.py
new file mode 100644
index 00000000..2c71ba6d
--- /dev/null
+++ b/resources/master_data/scripts/_토목_손.py
@@ -0,0 +1,204 @@
+# -*- coding: utf-8 -*-
+"""토목 표 손 지정 — 칸 안에 줄이 여럿(
)이거나 줄마다 머리가 달라 자동으로 못 읽는 표. (build_소요량_건설품셈_토목.py 가 씀)"""
+
+import re
+
+from _md표_읽기 import N, R, join
+from _토목_읽기 import is_num, nz
+from _토목_표 import bigo_items, conds_out, crew, jn, matrix, members, rate, tpose
+
+SPECIAL = {"1": {}, "2": {}, "6": {}}
+
+
+def finish(rows, cols, bigo=(), label=None):
+ names = []
+ for r in rows:
+ for k in r:
+ if k not in names and k not in cols:
+ names.append(k)
+ return conds_out(names, rows), cols, rows, list(bigo), label
+
+
+def sp(ch, key):
+ def deco(fn):
+ SPECIAL[ch][key] = fn
+ return fn
+
+ return deco
+
+
+def lines(x):
+ return [p.strip() for p in x.split("
")]
+
+
+# ── 제1장 ──────────────────────────────────────────────────────
+sp("1", "1-8-9#4")(lambda t: (*tpose(t, "트럭", "작업"), None))
+sp("1", "1-8-2#2")(lambda t: (*tpose(t, "형식", "크레인"), None))
+sp("1", "1-8-14#2")(lambda t: (*rate(t), None))
+
+
+@sp("1", "1-9-1#1")
+def _(t):
+ R0, rows, cols = t.rows, [], {"시공량": "개소"}
+ heads = R0[2][5:8]
+ for lo, hi in ((3, 6), (6, 9)):
+ blk = R0[lo:hi]
+ c_, v = members(blk, 2, 1, 3)
+ cols.update(c_)
+ hts = lines(blk[0][4])
+ for j, hd in enumerate(heads):
+ vs = lines(blk[0][5 + j])
+ for i, hv in enumerate(hts):
+ if nz(vs[i]):
+ rows.append({"지주높이": hv, "지주 간격": hd, "시공량": N(vs[i]), **v})
+ return finish(rows, cols)
+
+
+@sp("1", "1-9-1#2")
+def _(t):
+ R0, rows, cols = t.rows, [], {"시공량": "개"}
+ heads = [jn(x) for x in R0[2][5:9]]
+ for lo, hi, hrows in ((3, 6, [3]), (6, 10, [6, 7, 8, 9])):
+ c_, v = members(R0[lo:hi], 2, 1, 3)
+ cols.update(c_)
+ for r in hrows:
+ for j, hd in enumerate(heads):
+ if nz(R0[r][5 + j]):
+ rows.append(
+ {
+ "지주높이": jn(R0[r][4]),
+ "방음벽 개당 면적": hd,
+ "시공량": N(R0[r][5 + j]),
+ **v,
+ }
+ )
+ return finish(rows, cols)
+
+
+@sp("1", "1-9-2#0")
+def _(t):
+ R0, rows, cols = t.rows, [], {"시공량": "m"}
+ for lo, hi in ((2, 5), (5, 8)):
+ blk = R0[lo:hi]
+ c_, v = members(blk, 3, 2, 4, 1)
+ cols.update(c_)
+ sizes = lines(blk[0][5])
+ for j, kind in enumerate((R0[1][6], R0[1][7])):
+ vs = lines(blk[0][6 + j])
+ for i, sz in enumerate(sizes):
+ rows.append(
+ {
+ "형식": jn(blk[0][0]),
+ "규격(아래폭+높이㎜)": sz,
+ "구간": kind,
+ "시공량": N(vs[i]),
+ **v,
+ }
+ )
+ return finish(rows, cols)
+
+
+@sp("1", "1-9-4#2")
+def _(t):
+ R0, rows, notes = t.rows, [], []
+ for row in R0[1:]:
+ if join(row[0]) == "산출기준":
+ notes = ["산출기준 " + x[len("비고 ") :] for x in bigo_items(row[1])]
+ continue
+ d = {"구분": jn(row[0]), "단위": row[1], "수량": N(row[2])}
+ if nz(row[3]):
+ d["비고"] = jn(row[3])
+ rows.append(d)
+ return conds_out(["구분"], rows), {"수량": "단위 칸 따름", "비고": "글"}, rows, notes, None
+
+
+# ── 제6장 ──────────────────────────────────────────────────────
+@sp("6", "6-1-1#0")
+def _(t):
+ rows = []
+ for row in t.rows[2:]:
+ d = {"구분": jn(row[0]), "내용": jn(row[1])}
+ for nm, c in (("요율 품", 2), ("요율 시공량", 3)):
+ if nz(row[c]):
+ d[nm] = R(row[c].rstrip("%"))
+ rows.append(d)
+ return (
+ conds_out(["구분"], rows),
+ {"내용": "글", "요율 품": "%", "요율 시공량": "%"},
+ rows,
+ [],
+ None,
+ )
+
+
+def multi(t):
+ """칸 안에 관경 · 시공량이 줄줄이 든 [구분 단위 수량 관경 시공량] 표."""
+ R0, rows, cols, bigo = t.rows, [], {}, []
+ unit = re.search(r"\((.*)\)", join(R0[0][4])).group(1)
+ cols["시공량"] = unit
+ blocks = []
+ for row in R0[1:]:
+ if join(row[0]) == "비고":
+ bigo += bigo_items(row[1])
+ elif nz(row[3]):
+ blocks.append([row])
+ else:
+ blocks[-1].append(row)
+ for blk in blocks:
+ c_, v = members(blk, 1, None, 2)
+ cols.update(c_)
+ ds, vs = lines(blk[0][3]), lines(blk[0][4])
+ assert len(ds) == len(vs), (t.ident, ds, vs)
+ for d, x in zip(ds, vs):
+ rows.append({"관경(㎜)": d, "시공량": N(x), **v})
+ return conds_out(["관경(㎜)"], rows), cols, rows, bigo, None
+
+
+for k in (
+ "6-2-1#0",
+ "6-2-1#1",
+ "6-2-2#0",
+ "6-2-2#1",
+ "6-3-1#0",
+ "6-3-1#1",
+ "6-4-1#0",
+ "6-4-2#0",
+ "6-5-1#0",
+ "6-5-2#0",
+ "6-5-3#0",
+ "6-5-4#0",
+ "6-6-1#0",
+ "6-6-2#0",
+):
+ SPECIAL["6"][k] = multi
+sp("6", "6-2-2#2")(lambda t: (*rate(t), None))
+sp("6", "6-5-4#1")(lambda t: (*rate(t), None))
+sp("6", "6-8-5#2")(lambda t: (*rate(t), None))
+sp("6", "6-8-4#2")(lambda t: (*matrix(t, "본관", "지관", "중량", "㎏", 2), None))
+sp("6", "6-7-5#0")(lambda t: (*crew(t, prefix_group=True), None))
+
+
+@sp("6", "6-3-2#0")
+def _(t):
+ R0, rows, cols = t.rows, [], {"시공량": "개소", "용접공": "인"}
+ heads = [
+ (jn(R0[0][4]), jn(R0[1][4]), jn(R0[2][4])),
+ (None, jn(R0[1][4]), jn(R0[2][5])),
+ (None, jn(R0[1][6]), jn(R0[2][6])),
+ ]
+ combos = [(4, "A종", "겹치기 용접"), (5, "A종", "베벨엔드용접"), (6, "B종", "겹치기 용접")]
+ for r in R0[3:]:
+ if nz(r[0]):
+ qty = N(r[2])
+ for c, a, b in combos:
+ if nz(r[c]):
+ rows.append(
+ {
+ "관경(㎜)": jn(r[3]),
+ "종별": a,
+ "용접방법": b,
+ "시공량": N(r[c]),
+ "용접공": qty,
+ }
+ )
+ return conds_out(["관경(㎜)", "종별", "용접방법"], rows), cols, rows, [], None
diff --git a/resources/master_data/scripts/_토목_읽기.py b/resources/master_data/scripts/_토목_읽기.py
new file mode 100644
index 00000000..594cb8ea
--- /dev/null
+++ b/resources/master_data/scripts/_토목_읽기.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+"""건설품셈 토목 제1 · 2 · 6장 md 읽기 · 표 만들기 · 쓰기 도구 (build_소요량_건설품셈_토목.py 가 씀).
+
+값은 md 에서 읽음(손으로 적지 않음). 표 모양 다섯 갈래: 인력·장비 편성표(crew) · 줄이 관경인 납작표(flat) ·
+요율 · 글 표 · 손 지정.
+"""
+
+import json
+import re
+from pathlib import Path
+
+from _md표_읽기 import N, R, Raw, join, js, unesc
+
+ROOT = Path(__file__).resolve().parents[3]
+BASE = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/본문/02_토목부문"
+DIV = "건설품셈 토목 "
+NUM = r"\d[\d,]*(?:\.\d+)?"
+UNITS = "인 hr 시간 ㎜ ㎡ ㎥ m % 대 회 식 일 본 개소 개 ㎏ kg ton 톤 m/일".split()
+ROLE = re.compile(r"^(수량|시공량|소요일수)")
+
+
+class Tab:
+ def __init__(self, ident, title, mok, base, rows, k):
+ self.ident, self.title, self.mok, self.base, self.rows, self.k = (
+ ident,
+ title,
+ mok,
+ base,
+ rows,
+ k,
+ )
+ self.notes: list[str] = []
+ self.pre = "" # 표 앞 글줄(별표 제목 등)
+
+
+def cells(line):
+ return [c.strip() for c in line.strip().strip("|").split("|")]
+
+
+def title_of(s):
+ return re.sub(r"\s*\((?:['‘’]\d\d[^)]*)\)\s*$", "", s).strip()
+
+
+def read_chapter(chdir):
+ """장 폴더의 항 · 표 · 주 를 차례로 읽음 → (표 목록, 항 본문 줄 사전)."""
+ tabs, text, order = [], {}, []
+ for p in sorted((next(BASE.glob(chdir + "*"))).glob("*.md")):
+ L = p.read_text(encoding="utf-8").split("\n")
+ at = [i for i, x in enumerate(L) if x.strip() == "---"]
+ L = L[at[1] + 1 :]
+ ident = title = mok = base = pre = ""
+ k = 0
+ in_note = False
+ i = 0
+ while i < len(L):
+ ln = L[i].rstrip()
+ m = re.match(r"^(#{2,4})\s+(.*)$", ln)
+ if m:
+ in_note = False
+ d = len(m.group(1))
+ if d == 3:
+ mm = re.match(r"^(\d+-\d+-\d+)\s+(.*)$", m.group(2))
+ ident, title, mok, k = mm.group(1), title_of(mm.group(2)), "", 0
+ order.append(ident)
+ text[ident] = []
+ elif d == 4:
+ mk = re.match(r"^(\d+)\.\s*(.*)$", m.group(2))
+ mok = title_of(mk.group(2)) if mk else title_of(m.group(2))
+ i += 1
+ continue
+ if not ident:
+ i += 1
+ continue
+ text[ident].append(ln)
+ if ln.startswith("|"):
+ j = i
+ rows = []
+ while j < len(L) and L[j].startswith("|"):
+ c = cells(L[j])
+ if not all(re.fullmatch(r":?-+:?", x) for x in c):
+ rows.append(c)
+ j += 1
+ b = base if re.fullmatch(r"\(.*당\)|\(m/일\)|\(.*\)", base) else ""
+ tabs.append(Tab(ident, title, mok, base[1:-1] if b else "", rows, k))
+ tabs[-1].pre = pre
+ k += 1
+ base = pre = ""
+ in_note = False
+ i = j
+ continue
+ s = ln.strip()
+ if s == "[주]":
+ in_note = True
+ elif in_note and s and not s.startswith(("