- 대한건설협회 임금실태조사 보고서 13회(2020.1 · 2020.9 · 2021.1 · 2021.9 ~ 2026.9) 원문 보관 — 2020.9 · 2021.1 은 게시판에 HWP 만 있어 HWP 로 보관 - 회차마다 전체본 md + 장별 md 4개(쪽 표기 · 표 좌표 재구성) — _pipeline/cak_report_md.py · cak_hwp_md.py - 표 안 수 기계 대조(cak_verify.py) — md 대 PDF 이상 0 · 회차 사이 충돌 1(3011 · 2020.1 원문 그대로) · 쪽 그림 대조 - 미공표직종_추적.md — 원문 4-라 산정 근거(마지막 공표값 · 전체직종 평균 · 이어 곱한 증가율) · ESTX 견줌 14 중 12 일치 - 마스터 미변경 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
214 lines
7.2 KiB
Python
214 lines
7.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""건설업 임금실태조사 보고서 HWP(5.x) → 전체본 md + 장별 md 4개. (PDF 가 없는 회차용)
|
||
|
||
사용: python cak_hwp_md.py <회차 폴더> ... (폴더 안에 .hwp 한 개)
|
||
- HWP 본문 레코드에서 글·표(칸 좌표 · 병합)를 직접 읽음. 쪽 구분은 HWP 에 없으므로 쪽 표기 없음.
|
||
- 검증은 cak_verify.py 의 회차 사이 대조(같은 공표일 값이 다른 회차 PDF 에도 실려 있음)로 함.
|
||
"""
|
||
|
||
import re
|
||
import struct
|
||
import sys
|
||
import zlib
|
||
from pathlib import Path
|
||
|
||
import olefile
|
||
|
||
NUM = r"\(※\d{1,3}(?:,\d{3})+\)|\d{1,3}(?:,\d{3})+|(?<![\d,])-(?![\d,])"
|
||
INFO = {"2020-09-01": ("2020-08-31", 5311), "2021-01-01": ("2021-01-01", 5312)}
|
||
CHAPTERS = [
|
||
("1. 조사개요", "Ⅰ.조사개요"),
|
||
("2. 임금적용요령", "Ⅱ.임금적용요령"),
|
||
("3. 개별직종 노임단가", "Ⅲ.개별직종노임단가"),
|
||
("4. 직종해설", "Ⅳ.직종해설"),
|
||
]
|
||
|
||
|
||
def para_text(b):
|
||
"""PARA_TEXT → 글. 제어문자(8 단위 차지) 건너뜀."""
|
||
u = struct.unpack("<%dH" % (len(b) // 2), b)
|
||
out, i = [], 0
|
||
while i < len(u):
|
||
c = u[i]
|
||
if c >= 32:
|
||
out.append(chr(c))
|
||
i += 1
|
||
elif c == 9:
|
||
out.append("\t")
|
||
i += 8
|
||
elif c in (1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||
i += 8
|
||
elif c in (10, 13):
|
||
i += 1
|
||
else:
|
||
i += 1
|
||
return "".join(out)
|
||
|
||
|
||
def read_blocks(path):
|
||
"""→ [("p", 글) | ("t", 칸목록[(row, col, colspan, rowspan, 글)] , 행수, 열수)]"""
|
||
o = olefile.OleFileIO(str(path))
|
||
comp = o.openstream("FileHeader").read()[36] & 1
|
||
recs = []
|
||
i = 0
|
||
while o.exists(f"BodyText/Section{i}"):
|
||
d = o.openstream(f"BodyText/Section{i}").read()
|
||
if comp:
|
||
d = zlib.decompress(d, -15)
|
||
p = 0
|
||
while p < len(d):
|
||
h = struct.unpack_from("<I", d, p)[0]
|
||
tag, lvl, sz = h & 0x3FF, (h >> 10) & 0x3FF, h >> 20
|
||
p += 4
|
||
if sz == 0xFFF:
|
||
sz = struct.unpack_from("<I", d, p)[0]
|
||
p += 4
|
||
recs.append((tag, lvl, d[p : p + sz]))
|
||
p += sz
|
||
i += 1
|
||
blocks = []
|
||
k = 0
|
||
while k < len(recs):
|
||
tag, lvl, b = recs[k]
|
||
if tag == 71 and b[:4] == b" lbt": # 표
|
||
tl = lvl
|
||
rows = cols = 0
|
||
cells = []
|
||
cur = None
|
||
k += 1
|
||
while k < len(recs) and (
|
||
recs[k][1] > tl or recs[k][0] in (72, 77) and recs[k][1] >= tl
|
||
):
|
||
t2, l2, b2 = recs[k]
|
||
if t2 == 77:
|
||
rows, cols = struct.unpack_from("<HH", b2, 4)
|
||
elif t2 == 72 and l2 == tl + 1:
|
||
col, row, cs, rs = struct.unpack_from("<HHHH", b2, 8)
|
||
cur = [row, col, cs, rs, []]
|
||
cells.append(cur)
|
||
elif t2 == 67 and cur is not None:
|
||
cur[4].append(para_text(b2).rstrip("\r"))
|
||
k += 1
|
||
blocks.append(
|
||
(
|
||
"t",
|
||
[
|
||
(r, c, cs, rs, "<br>".join(x.strip() for x in t if x.strip() != ""))
|
||
for r, c, cs, rs, t in cells
|
||
],
|
||
rows,
|
||
cols,
|
||
)
|
||
)
|
||
continue
|
||
if tag == 67:
|
||
blocks.append(("p", para_text(b).rstrip("\r")))
|
||
k += 1
|
||
return blocks
|
||
|
||
|
||
def esc(s):
|
||
return s.replace("|", "\\|")
|
||
|
||
|
||
def md_grid(cells, rows, cols):
|
||
g = [[""] * cols for _ in range(rows)]
|
||
for r, c, cs, rs, t in cells:
|
||
if r < rows and c < cols:
|
||
g[r][c] = t
|
||
o = []
|
||
for i, row in enumerate(g):
|
||
o.append("| " + " | ".join(esc(x) for x in row) + " |")
|
||
if i == 0:
|
||
o.append("|" + "---|" * cols)
|
||
return o
|
||
|
||
|
||
def jobs_table(cells):
|
||
"""개별직종 표 → 행 [(코드, 이름, 신뢰도, [4값])] · 머리 글"""
|
||
byrow = {}
|
||
for r, c, cs, rs, t in cells:
|
||
byrow.setdefault(r, {})[c] = t
|
||
rows, head = [], []
|
||
for r in sorted(byrow):
|
||
row = [byrow[r][c] for c in sorted(byrow[r])]
|
||
m = re.fullmatch(r"(\*{0,2})\s*(\d{4})", row[0].strip()) if row else None
|
||
if m and len(row) >= 6:
|
||
vals = row[-4:]
|
||
rows.append((m.group(2), "".join(row[1:-4]).replace(" ", ""), m.group(1), vals))
|
||
else:
|
||
head.append(row)
|
||
return rows, head
|
||
|
||
|
||
def convert(folder):
|
||
folder = Path(folder)
|
||
hwp = next(folder.glob("*.hwp"))
|
||
stem = hwp.stem
|
||
apply = folder.name
|
||
reg, seq = INFO[apply]
|
||
blocks = read_blocks(hwp)
|
||
title = f"{stem[:4]}년 {stem[4:6]} 적용"
|
||
hdr = (
|
||
f"> 원문: {hwp.name} (대한건설협회, 게시 {reg}, 적용 {apply})\n"
|
||
"> 변환: cak_hwp_md.py — HWP 본문에서 글 · 표 칸 좌표를 직접 읽음. HWP 에는 쪽 구분이 없어 쪽 표기 없음 · 원문 PDF 없음(쪽 그림 대조 불가) — "
|
||
"표 값은 같은 공표일이 실린 다른 회차 PDF 와 기계 대조(cak_verify.py). 표 안 글자 사이 벌림은 붙여 씀.\n\n"
|
||
)
|
||
# 장 나누기: 표 밖 문단 중 목차(탭 없는) 장 제목
|
||
idx = {}
|
||
for i, b in enumerate(blocks):
|
||
if b[0] == "p" and "\t" not in b[1]:
|
||
n = re.sub(r"\s", "", b[1])
|
||
for name, key in CHAPTERS:
|
||
if n == key and name not in idx:
|
||
idx[name] = i
|
||
order = [idx[n] for n, _ in CHAPTERS]
|
||
assert order == sorted(order), idx
|
||
bounds = order + [len(blocks)]
|
||
|
||
def render(bs):
|
||
out = []
|
||
for b in bs:
|
||
if b[0] == "p":
|
||
out.append(b[1] + " ")
|
||
continue
|
||
_, cells, rows, cols = b
|
||
jr, head = jobs_table(cells)
|
||
if len(jr) >= 10: # 개별직종 표
|
||
dates = []
|
||
for h in head:
|
||
dates += [x for x in h if re.fullmatch(r"\d{4}\.\d{1,2}\.\d{1,2}", x.strip())]
|
||
out.append("")
|
||
out.append("| 직종코드 | 직종명 | 신뢰도 | " + " | ".join(dates[:4]) + " |")
|
||
out.append("|---|---|---|---|---|---|---|")
|
||
for c, n, f, v in jr:
|
||
out.append(f"| {c} | {n} | {f} | " + " | ".join(esc(x) for x in v) + " |")
|
||
out.append("")
|
||
else:
|
||
out.append("")
|
||
out += md_grid(cells, rows, cols)
|
||
out.append("")
|
||
return out
|
||
|
||
pre = render(blocks[: order[0]])
|
||
chapters = {}
|
||
for j, (name, _) in enumerate(CHAPTERS):
|
||
chapters[name] = render(blocks[bounds[j] : bounds[j + 1]])
|
||
full_md = (
|
||
f"# {title}\n\n> 원본: `{hwp.name}`\n\n"
|
||
+ "\n".join(pre + [x for n, _ in CHAPTERS for x in chapters[n]])
|
||
+ "\n"
|
||
)
|
||
(folder / f"{stem}.md").write_text(full_md, encoding="utf-8")
|
||
for name, _ in CHAPTERS:
|
||
(folder / f"{name}.md").write_text(
|
||
f"# {name.split('. ', 1)[1]}\n\n{hdr}" + "\n".join(chapters[name]) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
return len([1 for b in blocks if b[0] == "t"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
for f in sys.argv[1:]:
|
||
print(f, "표", convert(f))
|