feat: [contracts] 계약서 버전 관리 시스템 구축
- DOCX 4종 → Markdown 미러링 체계 구축 (Git diff 추적) - DOCX에 개정이력 테이블 삽입 (Pretendard 9pt, 파란 헤더) - 자동화 스크립트 3종 (추출/삽입/동기화 검증) - revisions.json, CHANGELOG.md, INDEX.md 업데이트 - .gitignore에 contracts 경로 allowlist 추가
This commit is contained in:
334
sam/docs/contracts/scripts/extract_to_markdown.py
Normal file
334
sam/docs/contracts/scripts/extract_to_markdown.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DOCX → Markdown 추출 스크립트
|
||||
|
||||
4개 전자계약 DOCX 파일을 Markdown으로 변환한다.
|
||||
- 서비스이용계약서: Heading 스타일 기반 매핑
|
||||
- 나머지 3개: Bold 런 + 패턴 매칭으로 구조 유추
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
# 경로 설정
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DOCX_DIR = BASE_DIR / "docx"
|
||||
MD_DIR = BASE_DIR / "markdown"
|
||||
|
||||
# DOCX → Markdown 매핑
|
||||
FILE_MAP = {
|
||||
"01_고객_서비스이용계약서_v4_0_전자서명용.docx": {
|
||||
"output": "01-service-agreement.md",
|
||||
"title": "고객사 서비스 이용계약서",
|
||||
"type": "styled",
|
||||
},
|
||||
"비밀유지서약서.docx": {
|
||||
"output": "02-nda.md",
|
||||
"title": "비밀유지서약서 (NDA)",
|
||||
"type": "pattern",
|
||||
},
|
||||
"영업파트너 위촉계약서.docx": {
|
||||
"output": "03-partner-agreement.md",
|
||||
"title": "영업파트너 위촉계약서",
|
||||
"type": "pattern",
|
||||
},
|
||||
"영업파트너 위촉계약서(단체용).docx": {
|
||||
"output": "04-partner-agreement-group.md",
|
||||
"title": "영업파트너 위촉계약서 (단체용)",
|
||||
"type": "pattern",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def table_to_markdown(table):
|
||||
"""DOCX 테이블을 Markdown 테이블로 변환"""
|
||||
rows = []
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
# 헤더
|
||||
lines.append("| " + " | ".join(rows[0]) + " |")
|
||||
lines.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
|
||||
# 본문
|
||||
for row in rows[1:]:
|
||||
# 셀 개수 맞추기
|
||||
while len(row) < len(rows[0]):
|
||||
row.append("")
|
||||
lines.append("| " + " | ".join(row[: len(rows[0])]) + " |")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_paragraph_heading_level_styled(para):
|
||||
"""스타일 기반 문서의 헤딩 레벨 판별 (서비스이용계약서)"""
|
||||
style = para.style.name if para.style else ""
|
||||
|
||||
if style == "Heading 1":
|
||||
return 1
|
||||
elif style == "Heading 2":
|
||||
return 2
|
||||
elif style == "Heading 3":
|
||||
return 3
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def get_paragraph_heading_level_pattern(para):
|
||||
"""패턴 매칭 기반 문서의 헤딩 레벨 판별 (비밀유지서약서, 영업파트너 위촉계약서)"""
|
||||
text = para.text.strip()
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
|
||||
if not text or not has_bold:
|
||||
return 0
|
||||
|
||||
# "제X조" 패턴 → ## (h2)
|
||||
if re.match(r"^<?[ ]*제\d+조", text):
|
||||
return 2
|
||||
|
||||
# "X.X " 패턴 (소제목) → ### (h3)
|
||||
if re.match(r"^\d+\.\d+\s", text):
|
||||
return 3
|
||||
|
||||
# 문서 제목 (첫 번째 bold 텍스트)
|
||||
if re.match(r"^<?\s*(영업파트너|비밀유지서약서|Sales Partner)", text):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def is_list_item(para, doc_type):
|
||||
"""리스트 아이템인지 판별"""
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
if doc_type == "styled":
|
||||
style = para.style.name if para.style else ""
|
||||
return style == "Compact"
|
||||
|
||||
# pattern 기반: bold가 아닌 일반 텍스트이면서 제X조나 X.X 패턴이 아닌 것
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
if not has_bold and not re.match(r"^(제\d+조|<?|계약 당사자|\[)", text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def extract_styled_doc(doc, file_info):
|
||||
"""스타일 기반 문서 추출 (서비스이용계약서)"""
|
||||
lines = []
|
||||
table_positions = {}
|
||||
|
||||
# 테이블 위치 매핑: 문단 인덱스 기준으로 테이블이 어디에 삽입되는지 추적
|
||||
body = doc.element.body
|
||||
table_idx = 0
|
||||
para_idx = 0
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
if tag == "p":
|
||||
para_idx += 1
|
||||
elif tag == "tbl":
|
||||
table_positions[para_idx] = table_idx
|
||||
table_idx += 1
|
||||
|
||||
para_idx = 0
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
para = doc.paragraphs[para_idx]
|
||||
para_idx += 1
|
||||
text = para.text.strip()
|
||||
|
||||
if not text:
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
style = para.style.name if para.style else ""
|
||||
level = get_paragraph_heading_level_styled(para)
|
||||
|
||||
if level > 0:
|
||||
lines.append("")
|
||||
lines.append(f"{'#' * level} {text}")
|
||||
lines.append("")
|
||||
elif style == "Compact":
|
||||
# Bold 런이 있으면 강조 리스트
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
if has_bold:
|
||||
# Bold 부분과 일반 부분 분리
|
||||
parts = []
|
||||
for run in para.runs:
|
||||
if run.bold:
|
||||
parts.append(f"**{run.text}**")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
combined = "".join(parts)
|
||||
lines.append(f"- {combined}")
|
||||
else:
|
||||
# 들여쓰기된 하위 항목
|
||||
lines.append(f" - {text}")
|
||||
elif style in ("Body Text", "First Paragraph"):
|
||||
# 본문 텍스트
|
||||
if text.startswith("⚠️") or text.startswith("✅") or text.startswith("❌"):
|
||||
lines.append("")
|
||||
lines.append(f"> {text}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(text)
|
||||
else:
|
||||
lines.append(text)
|
||||
|
||||
elif tag == "tbl":
|
||||
if table_idx <= len(doc.tables):
|
||||
current_table_idx = sum(
|
||||
1
|
||||
for c in list(body)[: list(body).index(child)]
|
||||
if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl"
|
||||
)
|
||||
if current_table_idx < len(doc.tables):
|
||||
lines.append("")
|
||||
lines.append(table_to_markdown(doc.tables[current_table_idx]))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_pattern_doc(doc, file_info):
|
||||
"""패턴 매칭 기반 문서 추출 (비밀유지서약서, 영업파트너 위촉계약서)"""
|
||||
lines = []
|
||||
|
||||
body = doc.element.body
|
||||
para_idx = 0
|
||||
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
para = doc.paragraphs[para_idx]
|
||||
para_idx += 1
|
||||
text = para.text.strip()
|
||||
|
||||
if not text:
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
level = get_paragraph_heading_level_pattern(para)
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
|
||||
if level > 0:
|
||||
lines.append("")
|
||||
# 제목에서 < > 제거
|
||||
clean_text = re.sub(r"^<\s*|\s*>$", "", text).strip()
|
||||
lines.append(f"{'#' * level} {clean_text}")
|
||||
lines.append("")
|
||||
elif has_bold:
|
||||
# Bold 텍스트는 강조 처리
|
||||
parts = []
|
||||
for run in para.runs:
|
||||
if run.bold:
|
||||
parts.append(f"**{run.text}**")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
combined = "".join(parts)
|
||||
|
||||
# (1), (2) 같은 번호 패턴
|
||||
if re.match(r"^\*\*\(\d+\)", combined):
|
||||
lines.append(f"- {combined}")
|
||||
# "예시 N:", "Phase N:" 같은 패턴
|
||||
elif re.match(r"^\*\*(예시|Phase|별첨)\s", combined):
|
||||
lines.append("")
|
||||
lines.append(f"#### {text}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"- {combined}")
|
||||
else:
|
||||
# 일반 텍스트
|
||||
# 빈칸 양식 (___) 유지
|
||||
if "___" in text:
|
||||
lines.append(text)
|
||||
elif re.match(r"^(이메일|전화|주소|상호|대표|사업자|주민|연락처|날짜):", text):
|
||||
lines.append(f"- {text}")
|
||||
else:
|
||||
lines.append(f" - {text}")
|
||||
|
||||
elif tag == "tbl":
|
||||
current_table_idx = sum(
|
||||
1
|
||||
for c in list(body)[: list(body).index(child)]
|
||||
if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl"
|
||||
)
|
||||
if current_table_idx < len(doc.tables):
|
||||
lines.append("")
|
||||
lines.append(table_to_markdown(doc.tables[current_table_idx]))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def add_frontmatter(content, file_info, docx_name):
|
||||
"""YAML 프론트매터 추가"""
|
||||
frontmatter = f"""---
|
||||
title: "{file_info['title']}"
|
||||
version: "v4.0"
|
||||
date: "{date.today().isoformat()}"
|
||||
docx_file: "{docx_name}"
|
||||
---
|
||||
"""
|
||||
return frontmatter + "\n" + content
|
||||
|
||||
|
||||
def extract_file(docx_name, file_info):
|
||||
"""단일 DOCX 파일 추출"""
|
||||
docx_path = DOCX_DIR / docx_name
|
||||
if not docx_path.exists():
|
||||
print(f" [SKIP] {docx_name} - 파일 없음")
|
||||
return False
|
||||
|
||||
doc = Document(str(docx_path))
|
||||
|
||||
if file_info["type"] == "styled":
|
||||
content = extract_styled_doc(doc, file_info)
|
||||
else:
|
||||
content = extract_pattern_doc(doc, file_info)
|
||||
|
||||
# 프론트매터 추가
|
||||
content = add_frontmatter(content, file_info, docx_name)
|
||||
|
||||
# 연속 빈 줄 정리 (3줄 이상 → 2줄로)
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
# 파일 저장
|
||||
output_path = MD_DIR / file_info["output"]
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
print(f" [OK] {docx_name} → {file_info['output']}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("DOCX → Markdown 추출 시작")
|
||||
print(f" DOCX 디렉토리: {DOCX_DIR}")
|
||||
print(f" 출력 디렉토리: {MD_DIR}")
|
||||
print()
|
||||
|
||||
MD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
success = 0
|
||||
for docx_name, file_info in FILE_MAP.items():
|
||||
if extract_file(docx_name, file_info):
|
||||
success += 1
|
||||
|
||||
print(f"\n완료: {success}/{len(FILE_MAP)} 파일 변환됨")
|
||||
return 0 if success == len(FILE_MAP) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
348
sam/docs/contracts/scripts/insert_revision_table.py
Normal file
348
sam/docs/contracts/scripts/insert_revision_table.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DOCX 개정이력 테이블 삽입 스크립트
|
||||
|
||||
revisions.json을 읽어 각 DOCX 문서의 제목 직후에 개정이력 테이블을 삽입한다.
|
||||
- 삽입 위치: 문서 제목(첫 번째 Heading 또는 Bold 텍스트) 직후
|
||||
- 스타일: Pretendard 9pt, 연한 파란 헤더, 회색 테두리
|
||||
- 원본 백업 후 삽입
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import parse_xml
|
||||
from docx.oxml.ns import nsdecls, qn
|
||||
from docx.shared import Pt, RGBColor
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DOCX_DIR = BASE_DIR / "docx"
|
||||
BACKUP_DIR = BASE_DIR / "docx" / "backup"
|
||||
REVISIONS_FILE = BASE_DIR / "revisions.json"
|
||||
|
||||
# 스타일 설정
|
||||
FONT_NAME = "Pretendard"
|
||||
FONT_NAME_FALLBACK = "맑은 고딕"
|
||||
FONT_SIZE = Pt(9)
|
||||
HEADER_BG_COLOR = "D6E4F0" # 연한 파란색
|
||||
BORDER_COLOR = "999999" # 회색 테두리
|
||||
HEADER_FONT_COLOR = RGBColor(0x2B, 0x47, 0x6B) # 진한 파란 텍스트
|
||||
|
||||
|
||||
def set_cell_border(cell, **kwargs):
|
||||
"""셀 테두리 설정"""
|
||||
tc = cell._tc
|
||||
tcPr = tc.get_or_add_tcPr()
|
||||
|
||||
tcBorders = parse_xml(
|
||||
f'<w:tcBorders {nsdecls("w")}>'
|
||||
f' <w:top w:val="single" w:sz="4" w:color="{BORDER_COLOR}"/>'
|
||||
f' <w:left w:val="single" w:sz="4" w:color="{BORDER_COLOR}"/>'
|
||||
f' <w:bottom w:val="single" w:sz="4" w:color="{BORDER_COLOR}"/>'
|
||||
f' <w:right w:val="single" w:sz="4" w:color="{BORDER_COLOR}"/>'
|
||||
f"</w:tcBorders>"
|
||||
)
|
||||
tcPr.append(tcBorders)
|
||||
|
||||
|
||||
def set_cell_shading(cell, color):
|
||||
"""셀 배경색 설정"""
|
||||
tc = cell._tc
|
||||
tcPr = tc.get_or_add_tcPr()
|
||||
shading = parse_xml(
|
||||
f'<w:shd {nsdecls("w")} w:fill="{color}" w:val="clear"/>'
|
||||
)
|
||||
tcPr.append(shading)
|
||||
|
||||
|
||||
def set_run_font(run, bold=False):
|
||||
"""런의 폰트 설정"""
|
||||
run.font.size = FONT_SIZE
|
||||
run.font.bold = bold
|
||||
|
||||
# Pretendard 설정 (없으면 맑은 고딕 폴백)
|
||||
run.font.name = FONT_NAME
|
||||
r = run._element
|
||||
rPr = r.get_or_add_rPr()
|
||||
rFonts = parse_xml(
|
||||
f'<w:rFonts {nsdecls("w")} '
|
||||
f'w:ascii="{FONT_NAME}" '
|
||||
f'w:hAnsi="{FONT_NAME}" '
|
||||
f'w:eastAsia="{FONT_NAME}" '
|
||||
f'w:cs="{FONT_NAME_FALLBACK}"/>'
|
||||
)
|
||||
# 기존 rFonts 제거
|
||||
for existing in rPr.findall(qn("w:rFonts")):
|
||||
rPr.remove(existing)
|
||||
rPr.insert(0, rFonts)
|
||||
|
||||
|
||||
def create_revision_table(doc, revisions):
|
||||
"""개정이력 테이블 생성 (Document에 직접 추가하지 않고 XML 요소만 생성)"""
|
||||
# 테이블 생성
|
||||
headers = ["버전", "날짜", "작성자", "변경 내용"]
|
||||
num_cols = len(headers)
|
||||
num_rows = 1 + len(revisions)
|
||||
|
||||
table = doc.add_table(rows=num_rows, cols=num_cols)
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
|
||||
# 헤더 행 설정
|
||||
header_row = table.rows[0]
|
||||
for i, header_text in enumerate(headers):
|
||||
cell = header_row.cells[i]
|
||||
cell.text = ""
|
||||
paragraph = cell.paragraphs[0]
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(header_text)
|
||||
set_run_font(run, bold=True)
|
||||
run.font.color.rgb = HEADER_FONT_COLOR
|
||||
set_cell_shading(cell, HEADER_BG_COLOR)
|
||||
set_cell_border(cell)
|
||||
|
||||
# 데이터 행 설정 (최신 순)
|
||||
sorted_revisions = sorted(revisions, key=lambda r: r["date"], reverse=True)
|
||||
for row_idx, rev in enumerate(sorted_revisions):
|
||||
row = table.rows[row_idx + 1]
|
||||
values = [rev["version"], rev["date"], rev["author"], rev["description"]]
|
||||
for col_idx, value in enumerate(values):
|
||||
cell = row.cells[col_idx]
|
||||
cell.text = ""
|
||||
paragraph = cell.paragraphs[0]
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(value)
|
||||
set_run_font(run)
|
||||
set_cell_border(cell)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def find_title_paragraph_index(doc, doc_type="pattern"):
|
||||
"""문서 제목 문단의 인덱스를 찾는다"""
|
||||
for i, para in enumerate(doc.paragraphs):
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if doc_type == "styled":
|
||||
# Heading 1 스타일의 첫 번째 문단
|
||||
if para.style and para.style.name == "Heading 1":
|
||||
return i
|
||||
else:
|
||||
# 첫 번째 Bold 텍스트 (제목)
|
||||
has_bold = any(r.bold for r in para.runs if r.bold)
|
||||
if has_bold:
|
||||
return i
|
||||
|
||||
return 0 # 찾지 못하면 맨 앞
|
||||
|
||||
|
||||
def find_subtitle_index(doc, title_idx):
|
||||
"""제목 다음의 부제목(영문 제목 등) 인덱스를 찾는다"""
|
||||
# 제목 바로 다음 문단이 영문 부제목이면 그 다음에 삽입
|
||||
if title_idx + 1 < len(doc.paragraphs):
|
||||
next_para = doc.paragraphs[title_idx + 1]
|
||||
text = next_para.text.strip()
|
||||
if text and any(
|
||||
text.startswith(prefix)
|
||||
for prefix in ["Customer Service", "Sales Partner", "Non-Disclosure"]
|
||||
):
|
||||
return title_idx + 1
|
||||
return title_idx
|
||||
|
||||
|
||||
def insert_table_after_paragraph(doc, para_idx, table):
|
||||
"""특정 문단 인덱스 다음에 테이블을 이동"""
|
||||
body = doc.element.body
|
||||
|
||||
# 빈 문단 추가 (테이블 전 여백)
|
||||
spacer_before = parse_xml(
|
||||
f'<w:p {nsdecls("w")}>'
|
||||
f" <w:pPr><w:spacing w:before=\"120\" w:after=\"120\"/></w:pPr>"
|
||||
f"</w:p>"
|
||||
)
|
||||
|
||||
# 빈 문단 추가 (테이블 후 여백)
|
||||
spacer_after = parse_xml(
|
||||
f'<w:p {nsdecls("w")}>'
|
||||
f' <w:pPr><w:spacing w:before="120" w:after="120"/></w:pPr>'
|
||||
f"</w:p>"
|
||||
)
|
||||
|
||||
# "개정이력" 라벨 문단
|
||||
label_para = parse_xml(
|
||||
f'<w:p {nsdecls("w")}>'
|
||||
f" <w:pPr>"
|
||||
f" <w:jc w:val=\"center\"/>"
|
||||
f' <w:spacing w:before="200" w:after="80"/>'
|
||||
f" </w:pPr>"
|
||||
f" <w:r>"
|
||||
f" <w:rPr>"
|
||||
f' <w:rFonts w:ascii="{FONT_NAME}" w:hAnsi="{FONT_NAME}" '
|
||||
f' w:eastAsia="{FONT_NAME}" w:cs="{FONT_NAME_FALLBACK}"/>'
|
||||
f' <w:sz w:val="18"/>'
|
||||
f' <w:szCs w:val="18"/>'
|
||||
f" <w:b/>"
|
||||
f' <w:color w:val="666666"/>'
|
||||
f" </w:rPr>"
|
||||
f" <w:t>[ 개정이력 ]</w:t>"
|
||||
f" </w:r>"
|
||||
f"</w:p>"
|
||||
)
|
||||
|
||||
# 대상 문단의 XML 요소 찾기
|
||||
para_elements = body.findall(qn("w:p"))
|
||||
if para_idx >= len(para_elements):
|
||||
para_idx = len(para_elements) - 1
|
||||
|
||||
target_para = para_elements[para_idx]
|
||||
|
||||
# 테이블 XML 요소 (이미 doc에 추가되어 body 끝에 있음)
|
||||
table_element = table._tbl
|
||||
|
||||
# body에서 테이블 제거 (끝에서)
|
||||
body.remove(table_element)
|
||||
|
||||
# 대상 문단 다음에 삽입 (역순으로 삽입)
|
||||
target_para.addnext(spacer_after)
|
||||
target_para.addnext(table_element)
|
||||
target_para.addnext(label_para)
|
||||
target_para.addnext(spacer_before)
|
||||
|
||||
|
||||
def remove_existing_revision_table(doc):
|
||||
"""기존 개정이력 테이블이 있으면 제거"""
|
||||
body = doc.element.body
|
||||
|
||||
# "[ 개정이력 ]" 라벨 문단 찾기
|
||||
for para in body.findall(qn("w:p")):
|
||||
texts = para.findall(f".//{qn('w:t')}")
|
||||
full_text = "".join(t.text or "" for t in texts)
|
||||
if "개정이력" in full_text:
|
||||
# 이 문단과 바로 다음의 테이블, 그리고 전후 spacer 제거
|
||||
siblings = list(body)
|
||||
idx = siblings.index(para)
|
||||
|
||||
# 이전 spacer (빈 문단)
|
||||
if idx > 0:
|
||||
prev = siblings[idx - 1]
|
||||
prev_tag = prev.tag.split("}")[-1] if "}" in prev.tag else prev.tag
|
||||
if prev_tag == "p":
|
||||
prev_texts = prev.findall(f".//{qn('w:t')}")
|
||||
prev_full = "".join(t.text or "" for t in prev_texts)
|
||||
if not prev_full.strip():
|
||||
body.remove(prev)
|
||||
siblings = list(body)
|
||||
idx = siblings.index(para)
|
||||
|
||||
# 라벨 문단 다음의 테이블
|
||||
if idx + 1 < len(siblings):
|
||||
next_elem = siblings[idx + 1]
|
||||
next_tag = (
|
||||
next_elem.tag.split("}")[-1]
|
||||
if "}" in next_elem.tag
|
||||
else next_elem.tag
|
||||
)
|
||||
if next_tag == "tbl":
|
||||
body.remove(next_elem)
|
||||
siblings = list(body)
|
||||
|
||||
# 테이블 다음 spacer
|
||||
if idx + 1 < len(siblings):
|
||||
after = siblings[idx + 1]
|
||||
after_tag = (
|
||||
after.tag.split("}")[-1]
|
||||
if "}" in after.tag
|
||||
else after.tag
|
||||
)
|
||||
if after_tag == "p":
|
||||
after_texts = after.findall(f".//{qn('w:t')}")
|
||||
after_full = "".join(t.text or "" for t in after_texts)
|
||||
if not after_full.strip():
|
||||
body.remove(after)
|
||||
|
||||
# 라벨 문단 제거
|
||||
body.remove(para)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def process_document(docx_name, doc_info):
|
||||
"""단일 DOCX에 개정이력 테이블 삽입"""
|
||||
docx_path = DOCX_DIR / docx_name
|
||||
if not docx_path.exists():
|
||||
print(f" [SKIP] {docx_name} - 파일 없음")
|
||||
return False
|
||||
|
||||
# 백업
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = BACKUP_DIR / docx_name
|
||||
shutil.copy2(docx_path, backup_path)
|
||||
print(f" [BACKUP] {docx_name} → backup/")
|
||||
|
||||
doc = Document(str(docx_path))
|
||||
|
||||
# 기존 개정이력 테이블 제거
|
||||
if remove_existing_revision_table(doc):
|
||||
print(f" [INFO] 기존 개정이력 테이블 제거됨")
|
||||
|
||||
# 문서 유형 판별
|
||||
has_heading_styles = any(
|
||||
p.style and p.style.name.startswith("Heading")
|
||||
for p in doc.paragraphs
|
||||
)
|
||||
doc_type = "styled" if has_heading_styles else "pattern"
|
||||
|
||||
# 제목 위치 찾기
|
||||
title_idx = find_title_paragraph_index(doc, doc_type)
|
||||
# 부제목(영문 제목) 확인
|
||||
insert_after_idx = find_subtitle_index(doc, title_idx)
|
||||
|
||||
# 테이블 생성
|
||||
table = create_revision_table(doc, doc_info["revisions"])
|
||||
|
||||
# 테이블 삽입
|
||||
insert_table_after_paragraph(doc, insert_after_idx, table)
|
||||
|
||||
# 저장
|
||||
doc.save(str(docx_path))
|
||||
print(f" [OK] {docx_name} - 개정이력 테이블 삽입 완료")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("DOCX 개정이력 테이블 삽입 시작")
|
||||
print(f" DOCX 디렉토리: {DOCX_DIR}")
|
||||
print(f" 개정 데이터: {REVISIONS_FILE}")
|
||||
print()
|
||||
|
||||
# revisions.json 로드
|
||||
if not REVISIONS_FILE.exists():
|
||||
print(f"[ERROR] {REVISIONS_FILE} 파일을 찾을 수 없습니다.")
|
||||
return 1
|
||||
|
||||
with open(REVISIONS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
documents = data.get("documents", {})
|
||||
|
||||
success = 0
|
||||
for doc_key, doc_info in documents.items():
|
||||
docx_name = doc_info["docx_file"]
|
||||
print(f"처리 중: {doc_info['title']}")
|
||||
if process_document(docx_name, doc_info):
|
||||
success += 1
|
||||
print()
|
||||
|
||||
print(f"완료: {success}/{len(documents)} 파일 처리됨")
|
||||
return 0 if success == len(documents) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
255
sam/docs/contracts/scripts/sync_check.py
Normal file
255
sam/docs/contracts/scripts/sync_check.py
Normal file
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DOCX ↔ Markdown 동기화 검증 스크립트
|
||||
|
||||
DOCX에서 텍스트를 추출하고 Markdown 파일의 텍스트와 비교하여
|
||||
불일치 항목을 리포트한다.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DOCX_DIR = BASE_DIR / "docx"
|
||||
MD_DIR = BASE_DIR / "markdown"
|
||||
|
||||
# DOCX → Markdown 파일 매핑
|
||||
FILE_MAP = {
|
||||
"01_고객_서비스이용계약서_v4_0_전자서명용.docx": "01-service-agreement.md",
|
||||
"비밀유지서약서.docx": "02-nda.md",
|
||||
"영업파트너 위촉계약서.docx": "03-partner-agreement.md",
|
||||
"영업파트너 위촉계약서(단체용).docx": "04-partner-agreement-group.md",
|
||||
}
|
||||
|
||||
|
||||
def extract_text_from_docx(docx_path):
|
||||
"""DOCX에서 순수 텍스트만 추출 (개정이력 테이블 제외, 인터리빙 방식)"""
|
||||
doc = Document(str(docx_path))
|
||||
lines = []
|
||||
|
||||
from docx.oxml.ns import qn as _qn
|
||||
|
||||
body = doc.element.body
|
||||
para_idx = 0
|
||||
table_idx = 0
|
||||
skip_revision = False
|
||||
|
||||
for child in body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
if para_idx < len(doc.paragraphs):
|
||||
text = doc.paragraphs[para_idx].text.strip()
|
||||
para_idx += 1
|
||||
|
||||
if "개정이력" in text:
|
||||
skip_revision = True
|
||||
continue
|
||||
if text:
|
||||
skip_revision = False
|
||||
lines.append(text)
|
||||
|
||||
elif tag == "tbl":
|
||||
if table_idx < len(doc.tables):
|
||||
table = doc.tables[table_idx]
|
||||
table_idx += 1
|
||||
|
||||
# 개정이력 테이블 건너뛰기
|
||||
if len(table.rows) > 0:
|
||||
first_row_text = [cell.text.strip() for cell in table.rows[0].cells]
|
||||
if "버전" in first_row_text and "날짜" in first_row_text:
|
||||
skip_revision = False
|
||||
continue
|
||||
|
||||
if skip_revision:
|
||||
skip_revision = False
|
||||
continue
|
||||
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(cell.text.strip() for cell in row.cells)
|
||||
if row_text.strip():
|
||||
lines.append(row_text)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def extract_text_from_markdown(md_path):
|
||||
"""Markdown에서 순수 텍스트만 추출 (프론트매터, 마크업 제거)"""
|
||||
content = md_path.read_text(encoding="utf-8")
|
||||
lines = []
|
||||
|
||||
in_frontmatter = False
|
||||
in_table = False
|
||||
|
||||
for line in content.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
# YAML 프론트매터 건너뛰기
|
||||
if stripped == "---":
|
||||
in_frontmatter = not in_frontmatter
|
||||
continue
|
||||
if in_frontmatter:
|
||||
continue
|
||||
|
||||
# 빈 줄 건너뛰기
|
||||
if not stripped:
|
||||
in_table = False
|
||||
continue
|
||||
|
||||
# Markdown 마크업 제거
|
||||
text = stripped
|
||||
|
||||
# 헤딩 마크업 제거
|
||||
text = re.sub(r"^#{1,6}\s+", "", text)
|
||||
|
||||
# 리스트 마크업 제거
|
||||
text = re.sub(r"^\s*[-*+]\s+", "", text)
|
||||
|
||||
# Bold/Italic 마크업 제거
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
|
||||
# 블록인용 제거
|
||||
text = re.sub(r"^>\s*", "", text)
|
||||
|
||||
# 테이블 구분선 건너뛰기
|
||||
if re.match(r"^\|[\s\-|]+\|$", text):
|
||||
continue
|
||||
|
||||
# 테이블 행
|
||||
if text.startswith("|") and text.endswith("|"):
|
||||
# 파이프 제거하고 셀 텍스트 추출
|
||||
cells = [c.strip() for c in text.strip("|").split("|")]
|
||||
text = " | ".join(cells)
|
||||
|
||||
text = text.strip()
|
||||
if text:
|
||||
lines.append(text)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def normalize_text(text):
|
||||
"""비교를 위한 텍스트 정규화"""
|
||||
# 공백 정규화
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
# 특수문자 정규화
|
||||
text = text.replace("\u00a0", " ") # non-breaking space
|
||||
text = text.replace("\u3000", " ") # ideographic space
|
||||
# 언더스코어 빈칸 정규화
|
||||
text = re.sub(r"_{3,}", "___", text)
|
||||
return text
|
||||
|
||||
|
||||
def compare_documents(docx_name, md_name):
|
||||
"""두 문서의 텍스트를 비교"""
|
||||
docx_path = DOCX_DIR / docx_name
|
||||
md_path = MD_DIR / md_name
|
||||
|
||||
if not docx_path.exists():
|
||||
return {"status": "error", "message": f"DOCX 파일 없음: {docx_name}"}
|
||||
if not md_path.exists():
|
||||
return {"status": "error", "message": f"Markdown 파일 없음: {md_name}"}
|
||||
|
||||
docx_lines = [normalize_text(l) for l in extract_text_from_docx(docx_path) if l.strip()]
|
||||
md_lines = [normalize_text(l) for l in extract_text_from_markdown(md_path) if l.strip()]
|
||||
|
||||
# difflib로 비교
|
||||
matcher = difflib.SequenceMatcher(None, docx_lines, md_lines)
|
||||
ratio = matcher.ratio()
|
||||
|
||||
# 차이점 추출
|
||||
diffs = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
continue
|
||||
elif tag == "replace":
|
||||
for idx in range(max(i2 - i1, j2 - j1)):
|
||||
docx_text = docx_lines[i1 + idx] if i1 + idx < i2 else "(없음)"
|
||||
md_text = md_lines[j1 + idx] if j1 + idx < j2 else "(없음)"
|
||||
diffs.append({
|
||||
"type": "변경",
|
||||
"docx": docx_text[:80],
|
||||
"markdown": md_text[:80],
|
||||
})
|
||||
elif tag == "delete":
|
||||
for idx in range(i1, i2):
|
||||
diffs.append({
|
||||
"type": "DOCX에만 존재",
|
||||
"docx": docx_lines[idx][:80],
|
||||
"markdown": "-",
|
||||
})
|
||||
elif tag == "insert":
|
||||
for idx in range(j1, j2):
|
||||
diffs.append({
|
||||
"type": "Markdown에만 존재",
|
||||
"docx": "-",
|
||||
"markdown": md_lines[idx][:80],
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"similarity": round(ratio * 100, 1),
|
||||
"docx_lines": len(docx_lines),
|
||||
"md_lines": len(md_lines),
|
||||
"diff_count": len(diffs),
|
||||
"diffs": diffs[:20], # 상위 20개만
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("DOCX ↔ Markdown 동기화 검증")
|
||||
print("=" * 70)
|
||||
|
||||
all_ok = True
|
||||
|
||||
for docx_name, md_name in FILE_MAP.items():
|
||||
print(f"\n{'─' * 50}")
|
||||
print(f"문서: {docx_name}")
|
||||
print(f" ↔ {md_name}")
|
||||
print(f"{'─' * 50}")
|
||||
|
||||
result = compare_documents(docx_name, md_name)
|
||||
|
||||
if result["status"] == "error":
|
||||
print(f" [ERROR] {result['message']}")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
similarity = result["similarity"]
|
||||
status_icon = "OK" if similarity >= 80 else "WARN" if similarity >= 60 else "FAIL"
|
||||
|
||||
print(f" 유사도: {similarity}% [{status_icon}]")
|
||||
print(f" DOCX 라인: {result['docx_lines']}")
|
||||
print(f" Markdown 라인: {result['md_lines']}")
|
||||
print(f" 차이점: {result['diff_count']}개")
|
||||
|
||||
if result["diffs"]:
|
||||
print(f"\n 주요 차이점 (상위 {min(len(result['diffs']), 10)}개):")
|
||||
for i, diff in enumerate(result["diffs"][:10]):
|
||||
print(f" [{diff['type']}]")
|
||||
if diff["docx"] != "-":
|
||||
print(f" DOCX: {diff['docx']}")
|
||||
if diff["markdown"] != "-":
|
||||
print(f" MD: {diff['markdown']}")
|
||||
|
||||
if similarity < 80:
|
||||
all_ok = False
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
if all_ok:
|
||||
print("결과: 모든 문서 동기화 상태 양호")
|
||||
else:
|
||||
print("결과: 일부 문서에서 불일치 발견 - 확인 필요")
|
||||
print(f"{'=' * 70}")
|
||||
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user