Files
hermes-skills/skills/legal/contract-editor/references/comment-restoration-from-original.md
T

5.4 KiB

Comment Restoration from Original File

When comments are lost during docx editing (e.g., paragraph clear operations that remove commentRangeStart/End/Reference elements), restore them from the original file.

Scenario

  • Original file has N comments (e.g., Alice×2, 法务, 杜律 = 4 comments, ids 0-3)
  • Edited file lost some/all original comments and may have added new ones (e.g., 华诚-Z comment id=0, Alice id=2)
  • Goal: merge all comments — original ones preserved + new ones added, with non-conflicting IDs

Recovery Technique

Step 1: Extract original comments

WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'

z_orig = zipfile.ZipFile('original.docx')
with z_orig.open('word/comments.xml') as f:
    ctree_orig = etree.parse(f)
orig_comments = []
for c in ctree_orig.getroot().findall(f'{WNS}comment'):
    orig_comments.append({
        'id': c.get(f'{WNS}id'),
        'author': c.get(f'{WNS}author'),
        'date': c.get(f'{WNS}date'),
        'text': ''.join(t.text for t in c.iter(f'{WNS}t') if t.text),
        'element': copy.deepcopy(c)
    })
z_orig.close()

Step 2: Identify which comments survived in the edited file

z_edit = zipfile.ZipFile('edited.docx')
with z_edit.open('word/comments.xml') as f:
    ctree_edit = etree.parse(f)
edit_comment_ids = set()
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
    edit_comment_ids.add(c.get(f'{WNS}id'))

Step 3: Find new comments (non-original authors)

new_comments = []
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
    if c.get(f'{WNS}author') not in [oc['author'] for oc in orig_comments]:
        new_comments.append({
            'old_id': c.get(f'{WNS}id'),
            'author': c.get(f'{WNS}author'),
            'element': copy.deepcopy(c)
        })

Step 4: Rebuild comments.xml with all comments

Assign non-conflicting IDs:

  • Original comments keep their original IDs (0, 1, 2, 3)
  • New comments get IDs starting from max(original_ids) + 1
new_comments_xml = etree.Element(f'{WNS}comments')
new_comments_xml.set('xmlns:w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
# ... add other namespaces as needed

max_id = max(int(oc['id']) for oc in orig_comments)

# Add original comments
for oc in orig_comments:
    new_comments_xml.append(oc['element'])

# Add new comments with renumbered IDs
for nc in new_comments:
    max_id += 1
    nc['new_id'] = str(max_id)
    nc['element'].set(f'{WNS}id', nc['new_id'])
    new_comments_xml.append(nc['element'])

Step 5: Update document.xml comment references

For each new comment, find its commentRangeStart, commentRangeEnd, and commentReference in document.xml and update the ID from old to new:

for nc in new_comments:
    old_id = nc['old_id']
    new_id = nc['new_id']
    
    # Update commentRangeStart
    for elem in root.iter(f'{WNS}commentRangeStart'):
        if elem.get(f'{WNS}id') == old_id:
            elem.set(f'{WNS}id', new_id)
    
    # Update commentRangeEnd
    for elem in root.iter(f'{WNS}commentRangeEnd'):
        if elem.get(f'{WNS}id') == old_id:
            elem.set(f'{WNS}id', new_id)
    
    # Update commentReference (inside w:r)
    for elem in root.iter(f'{WNS}commentReference'):
        if elem.get(f'{WNS}id') == old_id:
            elem.set(f'{WNS}id', new_id)

Step 6: Write back to docx

z_out = zipfile.ZipFile('output.docx', 'w')
# Copy all files from edited.docx except comments.xml and document.xml
for item in z_edit.namelist():
    if item not in ('word/comments.xml', 'word/document.xml'):
        z_out.writestr(item, z_edit.read(item))

# Write updated comments.xml
z_out.writestr('word/comments.xml', 
    etree.tostring(new_comments_xml, encoding='UTF-8', xml_declaration=True, standalone=True))

# Write updated document.xml
z_out.writestr('word/document.xml',
    etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))

z_edit.close()
z_out.close()

Verification

z = zipfile.ZipFile('output.docx')
with z.open('word/comments.xml') as f:
    ctree = etree.parse(f)
for c in ctree.getroot().findall(f'{WNS}comment'):
    print(f"  id={c.get(f'{WNS}id')} author={c.get(f'{WNS}author')}: {text[:80]}")

# Check all IDs referenced in document.xml exist in comments.xml
content = z.read('word/document.xml').decode('utf-8')
doc_ids = set(re.findall(r'commentRangeStart[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentRangeEnd[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentReference[^>]*w:id="(\d+)"', content))
comment_ids = set(c.get(f'{WNS}id') for c in ctree.getroot().findall(f'{WNS}comment'))
assert doc_ids == comment_ids, f"ID mismatch: doc={doc_ids} comments={comment_ids}"

Key Pitfall: Comment Text Extraction

When extracting comment text for comparison, comments may have nested <w:p> elements (multi-paragraph comments). Use .iter() not .findall() to get all text nodes.

Empirical Case (2026-07-01 反委托代发工资协议)

  • Original: 4 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3)
  • v1_doro_updated: 2 comments (华诚-Z id=0, Alice id=2) — lost Alice id=0/1, 法务, 杜律
  • Final: 5 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3, 华诚-Z id=4)
  • 华诚-Z's comment was id=0 in v1_doro_updated, renumbered to id=4 in final
  • All commentRangeStart/End/Reference IDs updated in document.xml accordingly