knowledge (구 Aislo-law 독립 저장소 → resources/knowledge 이관, 저장소 폐지): - 법령·행정규칙·표준시방서·교본 원문 + 기술문서 55건 + 실무 분석·종합비교 - 루트 지침 체계: README(지도)·00_운영지침·01_수집지침·02_분석지침· 03_미결_및_확인사항(교본 충돌 리스트 포함)·04_참조_법령기준_목록 - 기술문서 55건 원문 전수 검증 완료 (사방 설계홍수량 법정 기준 등 반영) - 정리: CAD·오피스 잔재 142건, 중복 zip 7건(413MB), 빈 폴더 30개 제거 resources 그룹 재편 (이름순 그룹핑): - app_branding(구 prog_icon.jpg)·app_policies(구 legal)· data_global_contours(구 grobal_contours)·data_rainfall_idf_cache(구 wamis_contours)· template_2dDrawing(구 dwg_analysis/templete — 오타 교정, 상수·경로 동기화) - dwg_analysis(분석 완료 1.8GB)·templates(빈 폴더)·templete_calc_cost.xlsx 삭제 - 참조 코드 5파일 경로 수정 + 프론트 재빌드 (구 경로 잔존 0) - .gitignore: resources 추적 전환, national_contours.gpkg(22GB) 영구 제외 - .env: knowledge 수집용 API 정보 주석 통합 (KCSC·법령센터·조달청 제비율) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""HWPX(zip+XML) → Markdown. 공백·표·박스·계층 구조를 보존한다.
|
||
|
||
HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생성하는 용도.
|
||
- 문단 <hp:p>, 텍스트 <hp:t>, 표 <hp:tbl>/<hp:tr>/<hp:tc> 를 문서 순서대로 처리
|
||
- 단일셀 표(글상자) → 인용블록(줄바꿈 보존)
|
||
- 번호체계(제N장 / N-N-N. / 1. / 가. / (1) / ①) → 헤딩·중첩 리스트
|
||
"""
|
||
import re, sys, zipfile
|
||
from pathlib import Path
|
||
import xml.etree.ElementTree as ET
|
||
|
||
NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
||
|
||
def _local(tag):
|
||
return tag.split("}")[-1]
|
||
|
||
def para_text(p):
|
||
buf = []
|
||
for el in p.iter():
|
||
t = _local(el.tag)
|
||
if t == "t":
|
||
buf.append("".join(el.itertext()))
|
||
elif t == "tab":
|
||
buf.append("\t")
|
||
elif t == "lineBreak":
|
||
buf.append("\n")
|
||
return "".join(buf)
|
||
|
||
def cell_paras(tc):
|
||
parts = []
|
||
for sub in tc.iter(f"{NS}p"):
|
||
s = para_text(sub).strip()
|
||
if s:
|
||
parts.append(s)
|
||
return parts
|
||
|
||
def cell_text(tc):
|
||
return " ".join(cell_paras(tc)).replace("|", "/")
|
||
|
||
def table_md(tbl):
|
||
"""(kind, value) 반환. kind = 'table' | 'box' | 'text'."""
|
||
rows = [tr.findall(f"{NS}tc") for tr in tbl.findall(f"{NS}tr")]
|
||
rows = [r for r in rows if r]
|
||
if not rows:
|
||
return ("text", "")
|
||
# 단일셀(1행 1열) = 글상자 → 줄바꿈 보존 박스
|
||
if len(rows) == 1 and len(rows[0]) == 1:
|
||
return ("box", cell_paras(rows[0][0]))
|
||
txt = [[cell_text(tc) for tc in r] for r in rows]
|
||
if len(txt) < 2 or max(len(r) for r in txt) < 2:
|
||
return ("text", "\n".join(" ".join(r) for r in txt))
|
||
w = max(len(r) for r in txt)
|
||
txt = [r + [""] * (w - len(r)) for r in txt]
|
||
out = ["| " + " | ".join(txt[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
|
||
for r in txt[1:]:
|
||
out.append("| " + " | ".join(c.replace("\n", "<br>") for c in r) + " |")
|
||
return ("table", "\n".join(out))
|
||
|
||
def walk(container, out):
|
||
for child in container:
|
||
if _local(child.tag) != "p":
|
||
continue
|
||
tbls = child.findall(f".//{NS}tbl")
|
||
if tbls:
|
||
for tb in tbls:
|
||
out.append(table_md(tb))
|
||
else:
|
||
out.append(("text", para_text(child)))
|
||
|
||
def extract(path):
|
||
z = zipfile.ZipFile(path)
|
||
secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n))
|
||
out = []
|
||
for sec in secs:
|
||
walk(ET.fromstring(z.read(sec)), out)
|
||
return out
|
||
|
||
# ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ──
|
||
CHAP = re.compile(r"^제\d+\s*장(\s|$)")
|
||
SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.)
|
||
JO = re.compile(r"^제\d+조(의\d+)?\s*\(")
|
||
MARKERS = [
|
||
("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")),
|
||
("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")),
|
||
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
|
||
("circ", re.compile(r"^([①-⑳])\s*(.*)$")),
|
||
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
|
||
]
|
||
|
||
def marker(s):
|
||
for k, rx in MARKERS:
|
||
m = rx.match(s)
|
||
if m:
|
||
return k, m.group(1), m.group(2)
|
||
return None, "", s
|
||
|
||
def structure(items, header):
|
||
"""(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용."""
|
||
lines = list(header)
|
||
stack = [] # 리스트 마커 종류 스택
|
||
for kind, val in items:
|
||
if kind == "table":
|
||
lines += ["", val, ""]
|
||
stack = []
|
||
continue
|
||
if kind == "box":
|
||
# 글상자 → 인용블록, 문단 줄바꿈 보존
|
||
lines.append("")
|
||
for p in val:
|
||
lines.append(f"> {p}")
|
||
lines.append("")
|
||
stack = []
|
||
continue
|
||
st = (val or "").strip()
|
||
if not st:
|
||
continue
|
||
# 헤딩류
|
||
if CHAP.match(st):
|
||
lines += ["", f"## {st}", ""]; stack = []; continue
|
||
if SECN.match(st):
|
||
lines += ["", f"### {st}", ""]; stack = []; continue
|
||
if JO.match(st):
|
||
m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S)
|
||
lines += ["", f"### {m.group(1)}", ""]
|
||
if m.group(2).strip():
|
||
lines.append(m.group(2).strip())
|
||
stack = []; continue
|
||
# 리스트 마커
|
||
k, mk, rest = marker(st)
|
||
if k:
|
||
if k in stack:
|
||
depth = stack.index(k)
|
||
del stack[depth + 1:]
|
||
else:
|
||
stack.append(k)
|
||
depth = len(stack) - 1
|
||
ind = " " * depth
|
||
lines.append(f"{ind}- {mk} {rest}".rstrip())
|
||
else:
|
||
# 리스트 진행 중이면 현재 깊이의 본문(연속 문단)으로 들여쓰기
|
||
if stack:
|
||
lines.append(" " * len(stack) + st)
|
||
else:
|
||
lines.append(st)
|
||
# 빈 줄 정리
|
||
md, blank = [], 0
|
||
for l in lines:
|
||
if l.strip() == "":
|
||
blank += 1
|
||
if blank > 1:
|
||
continue
|
||
else:
|
||
blank = 0
|
||
md.append(l)
|
||
out = "\n".join(md).strip() + "\n"
|
||
# HWP 사설영역·서로게이트 등 UTF-8 인코딩 불가 문자 제거(깨진 글리프)
|
||
out = "".join(c for c in out if not ("\ud800" <= c <= "\udfff"))
|
||
out = out.encode("utf-8", "ignore").decode("utf-8")
|
||
return out
|
||
|
||
def to_md(path):
|
||
items = extract(path)
|
||
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""]
|
||
return structure(items, header)
|
||
|
||
def hwp5_to_md(path, header=None):
|
||
"""구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존."""
|
||
import hwp5_text
|
||
items = hwp5_text.extract_items(str(path))
|
||
if header is None:
|
||
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""]
|
||
return structure(items, header)
|
||
|
||
if __name__ == "__main__":
|
||
for f in sys.argv[1:]:
|
||
p = Path(f)
|
||
p.with_suffix(".md").write_text(to_md(p), encoding="utf-8")
|
||
print(f"{p.name} → 재추출")
|