.docx 파일을 Word 변경 내용 추적(수정 모드)로 편집하기
개요
.docx 파일을 프로그래밍 방식으로 편집하여 변경 사항이 Microsoft Word에서 추적된 수정 내용으로 표시되도록 합니다. 삭제는 빨간색 취소선, 추가는 빨간색 밑줄로 표시되며 작성자/날짜 메타데이터가 여백에 나타납니다. 이는 python-docx의 상위 API(변경 추적을 지원하지 않음)가 아닌 lxml을 통한 OOXML w:ins/w:del 요소의 직접 XML 조작을 사용합니다.
의존성
pip install python-docx lxml
python-docx>= 1.2.0 — 초기 검사(단락 스타일, 실행 서식)에만 사용됩니다. 실제 변경 추적 쓰기에는 사용되지 않습니다.lxml>= 5.0 — 실제 작업을 수행하는 라이브러리로,.docxzip 내부의word/document.xml을 구문 분석하고 수정합니다.
둘 다 순수 Python 설치이며 시스템 수준 의존성이 없습니다.
사용 시기
- 사용자가
.docx를 편집하고 Word에서 변경 사항을 확인(修订模式 / Track Changes)하려는 경우 - 사용자가 검토자가 개별 편집을 승인/거부할 수 있는 수정된 원고를 보내야 하는 경우
- 단순 텍스트 대체만으로는 부족하고 변경 이력이 중요한 시나리오
사용하지 말아야 할 때
- 사용자가 최종 텍스트만 대체하면 되는 경우(수정 표시 불필요) —
python-docx를 직접 사용하세요 - 파일이
.doc(구 바이너리 형식)인 경우 — 지원되지 않습니다 - 사용자가 변경 내용 추적과 함께 머리글/바닥글/각주를 편집하려는 경우 — 이 기술은 본문 단락만 다룹니다
함께 사용하기
이 스킬은 office-docx-skills의 다른 스킬과 단독으로 또는 함께 사용할 수 있습니다.
word-default-formatting과 함께 사용: 수정된 텍스트가 패키지의 기본 Word 서식 프로필을 따르도록 합니다.word-formula-writing과 함께 사용: 수식 관련 편집이 Word 기본 편집 가능한 수식과 보이는 수정 표시로 표현되도록 합니다.- 서식과 수식이 모두 중요한 경우 세 스킬을 모두 함께 사용합니다.
OOXML 변경 추적 구조
.docx는 word/document.xml을 포함하는 zip 파일입니다. 변경 추적은 w:p(단락) 내부의 w:r(실행) 요소를 감싸는 두 가지 래퍼 요소를 사용합니다.
<!-- 삭제: 원래 실행을 감싸며, 텍스트 태그는 w:delText가 됩니다 -->
<w:del w:id="102" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- 원래 서식 유지 --></w:rPr>
<w:delText xml:space="preserve">여기에 이전 텍스트</w:delText>
</w:r>
</w:del>
<!-- 삽입: 복제된 서식으로 새 실행을 감쌉니다 -->
<w:ins w:id="103" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- 원래 실행에서 복제 --></w:rPr>
<w:t xml:space="preserve">여기에 새 텍스트</w:t>
</w:r>
</w:ins>
핵심 규칙:
w:del과w:ins는w:p의 직계 자식이며w:pPr의 형제입니다.- 각각 고유한
w:id(정수, 문서의 기존 ID와 충돌하지 않아야 함)가 필요합니다. w:author와w:date는 Word에서 여백 주석을 채웁니다.w:del내부의 텍스트 요소는 반드시w:delText여야 합니다(w:t가 아님).w:ins내부의 텍스트 요소는 일반w:t입니다.- 실행 속성(
w:rPr)은 글꼴/크기/굵게를 유지하기 위해 원래 실행에서 복제해야 합니다.
핵심 구현
재사용 가능한 구현은 이 스킬과 함께 tracked_change_editor.py에 있습니다. 코드를 직접 임포트하려면 해당 도우미 파일을 작업 디렉터리에 복사하세요.
from tracked_change_editor import TrackedChangeEditor
editor = TrackedChangeEditor('input.docx', author='사용자 이름')
for i, p in enumerate(editor.body_paras):
if '대상 텍스트' in editor._get_para_text(p):
editor.replace_paragraph_text(i, '대체 텍스트')
break
editor.save('output.docx')
tracked_change_editor.py는 중요한 메커니즘을 처리합니다:
- 새
w:ins/w:del노드를 만들기 전에 기존 개정 ID 스캔 - 단락의 기본
w:rPr을 복제하여 단락 서식 유지 - 최종
.docx파일로 이동하기 전에 안전한 임시 파일을 통해 쓰기
사용 흐름
editor = TrackedChangeEditor('input.docx', author='사용자 이름')
# 텍스트 내용으로 대상 단락 찾기
for i, p in enumerate(editor.body_paras):
if '대상 텍스트' in editor._get_para_text(p):
editor.replace_paragraph_text(i, '대체 텍스트')
break
editor.save('output.docx') # 항상 새 파일에 먼저 저장하세요
중요한 주의사항 — 편집 전 반드시 읽으세요
0. 모든 편집은 하나의 스크립트에서 수행하거나, 마지막으로 저장된 파일에서 연쇄적으로 작업하세요
7번의 편집이 필요하다면, 모두 단일 TrackedChangeEditor 인스턴스에 대해 하나의 스크립트 실행으로 수행한 다음 한 번에 저장하세요. 다음을 하지 마세요:
- 스크립트 A 실행 →
_revised.docx에 저장 - 원본 파일에서 스크립트 B 실행 →
_revised.docx에 저장 (스크립트 A의 작업을 덮어씀)
여러 스크립트 실행으로 분할해야 한다면, 각 후속 실행은 반드시 이전 출력 파일을 로드해야 합니다:
# 1차
editor = TrackedChangeEditor('original.docx')
# ... 편집 ...
editor.save('revised.docx')
# 2차 — revised에서 로드, original이 아님
teditor2 = TrackedChangeEditor('revised.docx')
# ... 추가 편집 ...
editor2.save('revised.docx')
이것이 중요한 이유: 원본 파일에서 로드하면 이전 차수의 모든 변경 추적이 폐기됩니다. 이것은 가장 흔한 실수로, 오류 없이 작업을 조용히 삭제합니다.
0b. 새 단락 삽입: 텍스트가 아닌 구역을 찾으세요
새 단락(예: 인용문 항목)을 삽입할 때, 텍스트 일치를 위해 문서 전체를 검색하지 마세요. 본문 인용에는 참조 항목과 동일한 저자 이름이 포함됩니다. if 'Acharya' in text와 같은 단순 검색은 먼저 본문 단락과 일치하여 참조 항목을 2절 중간에 삽입하게 됩니다.
올바른 접근 방식 — 먼저 구역 경계를 찾으세요:
# 참고문헌 구역이 시작되는 위치 찾기
refs_start_idx = None
for i, p in enumerate(editor.body_paras):
t = editor._get_para_text(p)
if t.strip() == '참고문헌':
refs_start_idx = i
break
# 참고문헌 구역 내에서만 검색
for i in range(refs_start_idx + 1, len(editor.body_paras)):
t = editor._get_para_text(editor.body_paras[i])
if t.startswith('Acharya') and 'Johnson' in t:
# 이 단락 뒤에 삽입
break
이것이 중요한 이유: 저자 이름은 본문 인용과 참조 항목 모두에 나타납니다. 구역을 인식하지 않는 검색 없이는 새 참조 단락이 본문 텍스트에 삽입되어 사용자가 Word에서 열기 전까지 발견하기 어려운 오류가 발생합니다.
0c. 학술 논문 단락 재작성: 모든 인용을 보존하세요
단락의 텍스트를 교체할 때는 원본의 모든 (저자 연도) 인용을 반드시 보존해야 합니다. 학술 논문에는 본문 인용과 일치하는 참조 목록이 있어야 합니다. 인용을 삭제하면 참조 항목이 고립되거나 논증이 약화됩니다.
올바른 접근 방식 — 재작성 전에 추출:
import re
old_text = editor._get_para_text(editor.body_paras[idx])
old_cites = re.findall(r'\([^)]*\d{4}[a-z]?\)', old_text)
# old_cites의 모든 항목이 포함된 새 텍스트 작성
# 작성 후 확인:
for c in old_cites:
assert c in new_text, f'인용 누락: {c}'
이것이 중요한 이유: (Malmendier and Nagel 2011; Choi, Gao, and Jiang 2020)를 삭제하는 재작성된 서론은 논문의 인용 무결성을 조용히 손상시킵니다. 참조 목록에는 여전히 이 항목들이 있지만 텍스트에서는 아무 것도 가리키지 않습니다. 검토자와 교정자가 이를 지적할 것입니다.
1. 기존 변경 추적이 있는 단락
단락에 이전 편집의 w:ins/w:del이 이미 포함된 경우, _collect_direct_runs는 직접적인 w:r 자식만 가져옵니다. 즉, 기존 w:ins/w:del 래퍼 내부의 실행은 건너뜁니다. 이는 수정 중첩을 방지하지만, 대체가 "수락된" 텍스트 부분만 다루게 됨을 의미합니다. 이전 수정이 많은 단락의 경우 Word에서 먼저 모든 변경 사항을 수락한 후 다시 편집하세요.
2. 항상 새 파일로 저장하세요
원본을 덮어쓰지 마세요. _revised.docx로 저장하고, 사용자가 Word에서 확인한 후 수동으로 교체하게 하세요.
3. 목차(TOC)는 자동 업데이트되지 않습니다
python-docx/lxml은 Word의 목차를 새로 고칠 수 없습니다. 편집 후 사용자는 Word에서 열어 목차를 오른쪽 클릭한 후 '필드 업데이트'를 선택해야 합니다.
4. 개정 ID 고유성
_find_max_id() 메서드는 모든 기존 w:ins, w:del, w:rPrChange, w:pPrChange, w:sectPrChange 요소를 스캔합니다. 새 ID는 최대값에서 증가합니다. ID가 충돌하면 Word가 개정 기록을 손상시킬 수 있습니다.
5. 서식 보존
_get_default_rpr은 단락의 첫 번째 실행에서 w:rPr을 복제합니다. 이는 균일한 서식의 단락에 대해 작동합니다. 혼합 서식(예: 굵은 "키워드:" 다음에 일반 텍스트)이 있는 단락의 경우, 전체 단락 대체 대신 실행별로 외과적 대체를 수행하세요.
6. 왕복 정확도
lxml을 통한 저장은 약간의 XML 외관 차이(따옴표 스타일 " vs ', 줄 끝 \r\n vs \n)를 생성합니다. 내용과 서식은 보존됩니다. 파일 크기는 zip 압축 수준으로 인해 다를 수 있습니다 — 이는 무해합니다.
확인 체크리스트
저장 후 Word에서 열어 다음을 확인하세요:
- 삭제된 텍스트가 빨간색 취소선으로 표시됨
- 삽입된 텍스트가 빨간색 밑줄로 표시됨
- 작성자 이름이 여백 주석에 나타남
- 주변 단락이 원래 서식을 유지함
- 중복되거나 중첩된 수정 표시가 없음
- 선택한 작성자의 삽입 횟수가 예상과 일치함 — 확인 스크립트를 실행하여 작성자 이름으로
w:ins를 세고 각각의 처음 100자를 출력합니다. 횟수가 예상보다 적으면 이전 편집이 손실된 것입니다(주의사항 0 참조). - 본문 텍스트에 참조 항목이 없음 — 참고문헌 제목 앞의 단락을 스캔하여 전체 서지 항목(저자, 연도, 저널, 권, 페이지)처럼 보이는 단락이 있는지 확인합니다. 발견되면 참조가 잘못된 위치에 삽입된 것입니다(주의사항 0b 참조).
# 저장 후 확인 코드 조각
target_author = 'Codex'
with zipfile.ZipFile('output.docx') as z:
xml = z.read('word/document.xml')
root = etree.fromstring(xml)
author_ins = [e for e in root.findall('.//w:ins', nsmap)
if e.get(qn('w:author')) == target_author]
print(f'{target_author} 삽입 횟수: {len(author_ins)}')
for i, ins in enumerate(author_ins):
texts = ''.join(t.text for t in ins.findall('.//w:t', nsmap) if t.text)
print(f' [{i+1}] {texts[:100]}')