Initial commit: docx-review-changes pipx-installable CLI
Package the tracked-changes/comments extractor as a proper Python package with a console_scripts entry point so it can be installed via pipx.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# docx-review-changes
|
||||||
|
|
||||||
|
Extract all tracked changes (insertions, deletions, substitutions) and comments
|
||||||
|
from a Microsoft Word `.docx` file and print them as readable, paragraph-numbered
|
||||||
|
text — useful for reviewing edits without opening Word.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Requires Python 3.9+ and [pipx](https://pipx.pypa.io/).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pipx install git+https://gitea.reground.org/will/docx-review-changes.git
|
||||||
|
```
|
||||||
|
|
||||||
|
To upgrade to the latest version later:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pipx install --force git+https://gitea.reground.org/will/docx-review-changes.git
|
||||||
|
```
|
||||||
|
|
||||||
|
No external dependencies — the tool only uses the Python standard library.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docx-review-changes yourfile.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tracked change or comment is printed as a numbered entry with the
|
||||||
|
paragraph number, author, and surrounding context. Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
[1] CHANGE — paragraph 3 — Jane Doe
|
||||||
|
|
||||||
|
Delete : "quick"
|
||||||
|
Insert : "fast"
|
||||||
|
Context : The …[^^^] brown fox jumps over the lazy dog…
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
[2] COMMENT — paragraph 5 — John Smith
|
||||||
|
|
||||||
|
Highlighted : "lazy dog"
|
||||||
|
Comment : "Can we rename this?"
|
||||||
|
Context : …jumps over the [^^^]
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
Total: 2 item(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **CHANGE** — a deletion immediately followed by an insertion from the same
|
||||||
|
author is treated as one substitution.
|
||||||
|
- **DELETION** / **INSERTION** — standalone tracked changes.
|
||||||
|
- **COMMENT** — a comment anchored to highlighted text, with the comment body.
|
||||||
|
- `[^^^]` in the `Context` line marks where the change/comment sits relative
|
||||||
|
to the surrounding paragraph text.
|
||||||
|
|
||||||
|
If the document has no tracked changes or comments, the tool prints
|
||||||
|
`No tracked changes or comments found.`
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`.docx` files are zip archives containing OOXML. This tool reads
|
||||||
|
`word/document.xml` (paragraphs, `w:ins`/`w:del` tracked-change markup, and
|
||||||
|
comment anchors) and `word/comments.xml` (comment bodies) directly via
|
||||||
|
`zipfile` and `xml.etree.ElementTree` — no third-party libraries required.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "docx-review-changes"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Extract tracked changes and comments from a .docx file"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
docx-review-changes = "docx_review_changes.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["docx_review_changes*"]
|
||||||
Reference in New Issue
Block a user