파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량 재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만. - 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100) - `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100). `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외 - 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경) 두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물 폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가 `core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가 실제 포맷 차이를 가리고 있었음. 검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped / 0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음). 포맷터 재실행 시 prettier·biome 모두 변경 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
5.5 KiB
Python
172 lines
5.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""법령·기준 md 전수 품질 점검.
|
|
|
|
검출: 테이블깨짐 / 사진누락 / 내용누락 / 띄어쓰기소실 / 줄바꿈
|
|
소스 대조: 별표·첨부 md ↔ 같은 이름 PDF, 현행 본문 md ↔ 같은 이름 XML.
|
|
결과를 qc_report.json 으로 저장하고 카테고리별 요약 출력.
|
|
"""
|
|
|
|
import json, re
|
|
from pathlib import Path
|
|
import pymupdf
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
OUT = Path(__file__).resolve().parent / "data"
|
|
|
|
|
|
def norm(s):
|
|
return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s)
|
|
|
|
|
|
def strip_fenced(text):
|
|
"""``` 코드펜스 안을 빈 줄로 치환(위치 보존)."""
|
|
out, infence = [], False
|
|
for l in text.split(chr(10)):
|
|
if l.lstrip().startswith("```"):
|
|
infence = not infence
|
|
out.append("")
|
|
continue
|
|
out.append("" if infence else l)
|
|
return chr(10).join(out)
|
|
|
|
|
|
def ncols(line):
|
|
s = line.strip()
|
|
if s.startswith("|"):
|
|
s = s[1:]
|
|
if s.endswith("|"):
|
|
s = s[:-1]
|
|
return len(s.split("|"))
|
|
|
|
|
|
def check_tables(text):
|
|
"""마크다운 표 유효성. (문제 리스트) 반환."""
|
|
issues = []
|
|
lines = strip_fenced(text).split("\n")
|
|
i = 0
|
|
while i < len(lines):
|
|
if not lines[i].lstrip().startswith("|"):
|
|
i += 1
|
|
continue
|
|
start = i
|
|
block = []
|
|
while i < len(lines) and lines[i].lstrip().startswith("|"):
|
|
block.append(lines[i])
|
|
i += 1
|
|
head = ncols(block[0])
|
|
if len(block) < 2 or not set(
|
|
block[1].replace("|", "").replace(" ", "").replace(":", "")
|
|
) <= set("-"):
|
|
issues.append(f"L{start + 1} 구분선 없음/이상")
|
|
continue
|
|
for j, b in enumerate(block):
|
|
if j == 1:
|
|
continue
|
|
if ncols(b) != head:
|
|
issues.append(f"L{start + j + 1} 열수 {ncols(b)}≠{head}")
|
|
return issues
|
|
|
|
|
|
def check_space(text):
|
|
"""프로즈(표·펜스 제외)의 한글 12자 이상 연속 비율."""
|
|
prose = [l for l in strip_fenced(text).split(chr(10)) if not l.lstrip().startswith("|")]
|
|
t = chr(10).join(prose)
|
|
kor = len(re.findall(r"[가-힣]", t))
|
|
if kor < 400:
|
|
return 0.0
|
|
runs = re.findall(r"[가-힣]{12,}", t)
|
|
return round(sum(len(x) for x in runs) / kor, 3)
|
|
|
|
|
|
def check_linebreak(text):
|
|
"""줄바꿈 결함: 표 앞 빈 줄 없음, 헤딩 직후 표 붙음."""
|
|
issues = []
|
|
lines = strip_fenced(text).split("\n")
|
|
for i in range(1, len(lines)):
|
|
s = lines[i].strip()
|
|
prev = lines[i - 1].strip()
|
|
# 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님)
|
|
if (
|
|
s.startswith("|")
|
|
and prev
|
|
and not prev.startswith("|")
|
|
and not prev.startswith("#")
|
|
and not prev.startswith(">")
|
|
):
|
|
issues.append(f"L{i + 1} 표 앞 빈 줄 없음")
|
|
return issues[:5]
|
|
|
|
|
|
def pdf_stats(pdf):
|
|
d = pymupdf.open(pdf)
|
|
txt = "\n".join(p.get_text() for p in d)
|
|
imgs = sum(len(p.get_images()) for p in d)
|
|
return txt, imgs
|
|
|
|
|
|
def run():
|
|
report = []
|
|
targets = []
|
|
for pat in ("법률", "행정규칙", "표준시방서"):
|
|
base = ROOT / pat
|
|
targets += [p for p in base.rglob("*.md")]
|
|
targets += [p for p in (ROOT / "KS").glob("*.md")]
|
|
targets = [p for p in targets if p.name not in ("_목록.md", "_meta.md")]
|
|
|
|
for md in sorted(targets):
|
|
rel = str(md.relative_to(ROOT)).replace("\\", "/")
|
|
text = md.read_text(encoding="utf-8")
|
|
rec = {"file": rel, "issues": {}}
|
|
|
|
t = check_tables(text)
|
|
if t:
|
|
rec["issues"]["테이블"] = t[:6]
|
|
sp = check_space(text)
|
|
if sp > 0.15:
|
|
rec["issues"]["띄어쓰기"] = sp
|
|
lb = check_linebreak(text)
|
|
if lb:
|
|
rec["issues"]["줄바꿈"] = lb
|
|
|
|
# 소스 대조 (별표/첨부 → PDF). 단, HWP/HWPX 재추출본은 PDF가 소스가 아니므로 제외.
|
|
pdf = md.with_suffix(".pdf")
|
|
head = text[:200]
|
|
reextracted = ("재추출" in head) or (".hwp" in head) or (".hwpx" in head)
|
|
if pdf.exists() and not reextracted:
|
|
try:
|
|
ptxt, pimgs = pdf_stats(pdf)
|
|
pn, mn = norm(ptxt), norm(text)
|
|
if pn and len(mn) / len(pn) < 0.98:
|
|
rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn) / len(pn):.2f})"
|
|
mimg = text.count("〔그림〕") + text.count("![")
|
|
if pimgs > 0 and mimg == 0:
|
|
rec["issues"]["사진누락"] = f"PDF 이미지 {pimgs}개 / md 0"
|
|
except Exception as e:
|
|
rec["issues"]["PDF오류"] = str(e)[:50]
|
|
|
|
if rec["issues"]:
|
|
report.append(rec)
|
|
|
|
json.dump(
|
|
report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1
|
|
)
|
|
|
|
# 요약
|
|
cat = {}
|
|
for r in report:
|
|
for k in r["issues"]:
|
|
cat[k] = cat.get(k, 0) + 1
|
|
print(f"점검 {len(targets)}개 / 문제 파일 {len(report)}개")
|
|
print("카테고리별:", cat)
|
|
print("\n=== 심각(내용누락·사진누락·테이블) 상위 ===")
|
|
sev = [r for r in report if set(r["issues"]) & {"내용누락", "사진누락", "테이블"}]
|
|
for r in sev[:30]:
|
|
ks = ", ".join(
|
|
f"{k}={v if not isinstance(v, list) else len(v)}" for k, v in r["issues"].items()
|
|
)
|
|
print(f" {r['file'][-64:]} [{ks}]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|