6cc6c501f5
Package the tracked-changes/comments extractor as a proper Python package with a console_scripts entry point so it can be installed via pipx.
253 lines
7.6 KiB
Python
253 lines
7.6 KiB
Python
"""
|
|
Extract all tracked changes and comments from a .docx file.
|
|
Requires Python 3.6+ standard library only.
|
|
|
|
Usage:
|
|
docx-review-changes yourfile.docx
|
|
"""
|
|
|
|
import sys
|
|
import zipfile
|
|
import xml.etree.ElementTree as ET
|
|
|
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
|
|
|
|
def q(name):
|
|
return f'{{{W}}}{name}'
|
|
|
|
|
|
def el_text(el):
|
|
"""All w:t text inside el."""
|
|
return ''.join(c.text or '' for c in el.iter(q('t')))
|
|
|
|
|
|
def tokenise_para(para):
|
|
"""
|
|
Walk one paragraph and return an ordered list of tokens:
|
|
('text', str)
|
|
('del', author, str)
|
|
('ins', author, str)
|
|
('comment_start', id)
|
|
('comment_end', id)
|
|
"""
|
|
tokens = []
|
|
|
|
def walk(node):
|
|
for child in node:
|
|
tag = child.tag
|
|
if tag == q('commentRangeStart'):
|
|
tokens.append(('comment_start', child.get(q('id'), '')))
|
|
elif tag == q('commentRangeEnd'):
|
|
tokens.append(('comment_end', child.get(q('id'), '')))
|
|
elif tag == q('del'):
|
|
author = child.get(q('author'), 'unknown')
|
|
dt = ''.join(c.text or '' for c in child.iter(q('delText')))
|
|
if dt:
|
|
tokens.append(('del', author, dt))
|
|
elif tag == q('ins'):
|
|
author = child.get(q('author'), 'unknown')
|
|
it = ''.join(c.text or '' for c in child.iter(q('t')))
|
|
if it:
|
|
tokens.append(('ins', author, it))
|
|
elif tag == q('r'):
|
|
t = ''.join(c.text or '' for c in child.iter(q('t')))
|
|
if t:
|
|
tokens.append(('text', t))
|
|
else:
|
|
walk(child)
|
|
|
|
walk(para)
|
|
return tokens
|
|
|
|
|
|
def context_window(tokens, index, window=50):
|
|
"""
|
|
Return (before, after) plain-text snippets around the token at index.
|
|
"""
|
|
before_parts = []
|
|
for i in range(index - 1, -1, -1):
|
|
tok = tokens[i]
|
|
if tok[0] == 'text':
|
|
before_parts.insert(0, tok[1])
|
|
elif tok[0] in ('del', 'ins'):
|
|
before_parts.insert(0, tok[2])
|
|
if sum(len(s) for s in before_parts) >= window:
|
|
break
|
|
before = ''.join(before_parts)[-window:]
|
|
if len(before) == window:
|
|
before = '…' + before
|
|
|
|
after_parts = []
|
|
for i in range(index + 1, len(tokens)):
|
|
tok = tokens[i]
|
|
if tok[0] == 'text':
|
|
after_parts.append(tok[1])
|
|
elif tok[0] in ('del', 'ins'):
|
|
after_parts.append(tok[2])
|
|
if sum(len(s) for s in after_parts) >= window:
|
|
break
|
|
after = ''.join(after_parts)[:window]
|
|
if len(after) == window:
|
|
after = after + '…'
|
|
|
|
return before, after
|
|
|
|
|
|
def process_para(para, para_num, comment_map):
|
|
tokens = tokenise_para(para)
|
|
results = []
|
|
open_comments = {} # id -> token index where range started
|
|
i = 0
|
|
|
|
while i < len(tokens):
|
|
tok = tokens[i]
|
|
|
|
if tok[0] == 'comment_start':
|
|
open_comments[tok[1]] = i
|
|
|
|
elif tok[0] == 'comment_end':
|
|
cid = tok[1]
|
|
if cid in open_comments:
|
|
start = open_comments.pop(cid)
|
|
anchored = []
|
|
for j in range(start + 1, i):
|
|
t = tokens[j]
|
|
if t[0] == 'text':
|
|
anchored.append(t[1])
|
|
elif t[0] in ('del', 'ins'):
|
|
anchored.append(t[2])
|
|
anchored_text = ''.join(anchored).strip()
|
|
before, after = context_window(tokens, start)
|
|
if cid in comment_map:
|
|
author, ctext = comment_map[cid]
|
|
results.append({
|
|
'type': 'COMMENT',
|
|
'para': para_num,
|
|
'author': author,
|
|
'anchored': anchored_text or '(whole paragraph)',
|
|
'comment': ctext,
|
|
'before': before,
|
|
'after': after,
|
|
})
|
|
|
|
elif tok[0] == 'del':
|
|
author, deleted = tok[1], tok[2]
|
|
before, after = context_window(tokens, i)
|
|
# Look ahead: if the next change token is an ins from the same
|
|
# author, treat del+ins as a single substitution.
|
|
j = i + 1
|
|
while j < len(tokens) and tokens[j][0] not in ('del', 'ins', 'text'):
|
|
j += 1
|
|
if j < len(tokens) and tokens[j][0] == 'ins' and tokens[j][1] == author:
|
|
inserted = tokens[j][2]
|
|
results.append({
|
|
'type': 'CHANGE',
|
|
'para': para_num,
|
|
'author': author,
|
|
'deleted': deleted,
|
|
'inserted': inserted,
|
|
'before': before,
|
|
'after': after,
|
|
})
|
|
i = j # skip the paired ins
|
|
else:
|
|
results.append({
|
|
'type': 'DELETION',
|
|
'para': para_num,
|
|
'author': author,
|
|
'deleted': deleted,
|
|
'before': before,
|
|
'after': after,
|
|
})
|
|
|
|
elif tok[0] == 'ins':
|
|
author, inserted = tok[1], tok[2]
|
|
before, after = context_window(tokens, i)
|
|
results.append({
|
|
'type': 'INSERTION',
|
|
'para': para_num,
|
|
'author': author,
|
|
'inserted': inserted,
|
|
'before': before,
|
|
'after': after,
|
|
})
|
|
|
|
i += 1
|
|
|
|
return results
|
|
|
|
|
|
def format_results(all_results):
|
|
if not all_results:
|
|
print("No tracked changes or comments found.")
|
|
return
|
|
|
|
for n, r in enumerate(all_results, 1):
|
|
t = r['type']
|
|
print(f"{'─' * 60}")
|
|
print(f"[{n}] {t} — paragraph {r['para']} — {r['author']}")
|
|
print()
|
|
|
|
if t == 'COMMENT':
|
|
print(f" Highlighted : \"{r['anchored']}\"")
|
|
print(f" Comment : \"{r['comment']}\"")
|
|
print(f" Context : {r['before']}[^^^]{r['after']}")
|
|
|
|
elif t == 'CHANGE':
|
|
print(f" Delete : \"{r['deleted']}\"")
|
|
print(f" Insert : \"{r['inserted']}\"")
|
|
print(f" Context : {r['before']}[^^^]{r['after']}")
|
|
|
|
elif t == 'DELETION':
|
|
print(f" Delete : \"{r['deleted']}\"")
|
|
print(f" Context : {r['before']}[^^^]{r['after']}")
|
|
|
|
elif t == 'INSERTION':
|
|
print(f" Insert : \"{r['inserted']}\"")
|
|
print(f" Context : {r['before']}[^^^]{r['after']}")
|
|
|
|
print()
|
|
|
|
print(f"{'─' * 60}")
|
|
print(f"Total: {len(all_results)} item(s)")
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: docx-review-changes yourfile.docx")
|
|
sys.exit(1)
|
|
|
|
path = sys.argv[1]
|
|
|
|
with zipfile.ZipFile(path, 'r') as z:
|
|
names = z.namelist()
|
|
doc_xml = z.read('word/document.xml')
|
|
cmt_xml = z.read('word/comments.xml') if 'word/comments.xml' in names else None
|
|
|
|
comment_map = {}
|
|
if cmt_xml:
|
|
ct = ET.fromstring(cmt_xml)
|
|
for c in ct.findall(f'.//{q("comment")}'):
|
|
cid = c.get(q('id'), '')
|
|
author = c.get(q('author'), 'unknown')
|
|
text = el_text(c).strip()
|
|
comment_map[cid] = (author, text)
|
|
|
|
doc = ET.fromstring(doc_xml)
|
|
body = doc.find(f'.//{q("body")}')
|
|
|
|
all_results = []
|
|
para_num = 0
|
|
for elem in body:
|
|
if elem.tag == q('p'):
|
|
para_num += 1
|
|
results = process_para(elem, para_num, comment_map)
|
|
all_results.extend(results)
|
|
|
|
format_results(all_results)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|