northstar-ctx 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Zavodnick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: northstar-ctx
3
+ Version: 3.4.0
4
+ Summary: Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: defusedxml>=0.7
10
+ Requires-Dist: python-docx>=1.1
11
+ Dynamic: license-file
12
+
13
+ # northstar-ctx
14
+
15
+ Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
16
+
17
+ export CTX_AUTHOR="Your Name"
18
+ ctx-redline extract in.docx
19
+ ctx-redline apply in.docx plan.json --out out.docx
20
+ ctx-verify out.docx in.docx base.docx
21
+
22
+ Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
@@ -0,0 +1,10 @@
1
+ # northstar-ctx
2
+
3
+ Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
4
+
5
+ export CTX_AUTHOR="Your Name"
6
+ ctx-redline extract in.docx
7
+ ctx-redline apply in.docx plan.json --out out.docx
8
+ ctx-verify out.docx in.docx base.docx
9
+
10
+ Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
@@ -0,0 +1 @@
1
+ __version__ = "3.4.0"
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env python3
2
+ """NorthStar redline builder (ctx 3.4.0). Claude reads extract output, never the XML.
3
+ extract IN.docx | accept IN.docx OUT.docx [--keep "A,B"] [--ids 3,7] | apply IN.docx PLAN.json --out OUT.docx
4
+ [--report R.md] | check OUT.docx PLAN.json (also runs at the end of apply)
5
+ Plan: {"edits": [...], "questions": true}. Ops: replace (find, with), insert_after (find, text), delete (find),
6
+ insert_paragraph_after (text), delete_paragraph, comment (anchor, text, "question": true), reply (parent, text),
7
+ resolve (parent). find and anchor match one paragraph's rendered text; interior whitespace is normalized, leading
8
+ and trailing spaces are kept. Author and date are constants."""
9
+ from __future__ import annotations
10
+ import argparse, json, os, re, shutil, subprocess, sys, tempfile, zipfile
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ import defusedxml.minidom
14
+ from xml.dom import minidom
15
+
16
+ DOCX_SKILL = Path(os.environ.get("DOCX_SKILL", "/mnt/skills/public/docx/scripts"))
17
+ if not (DOCX_SKILL / "comment.py").exists():
18
+ sys.exit(f"HALT: docx skill scripts not found at {DOCX_SKILL} (set DOCX_SKILL).")
19
+ sys.path.insert(0, str(DOCX_SKILL))
20
+ from office.helpers import rezip, safe_extract # noqa: E402
21
+ import comment as docx_comment # noqa: E402
22
+
23
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
24
+ XML_NS = "http://www.w3.org/XML/1998/namespace"
25
+ AUTHOR = os.environ.get("CTX_AUTHOR", "").strip()
26
+ INITIALS = os.environ.get("CTX_INITIALS", "").strip() or "".join(w[0] for w in AUTHOR.split())[:3].upper()
27
+ if not AUTHOR:
28
+ sys.exit("HALT: set CTX_AUTHOR (the tracked-change author name) before running.")
29
+ WS = " \t\r\n "
30
+ TEXT_OPS = {"replace", "insert_after", "delete", "insert_paragraph_after", "delete_paragraph"}
31
+ OPS = TEXT_OPS | {"comment", "reply", "resolve"}
32
+ KEYS = {"id", "op", "find", "with", "text", "anchor", "parent", "question"}
33
+ MARKERS = {"w:commentRangeStart", "w:commentRangeEnd", "w:bookmarkStart", "w:bookmarkEnd"}
34
+ PR_CHANGES = ("w:rPrChange", "w:pPrChange", "w:sectPrChange", "w:tblPrChange", "w:tcPrChange",
35
+ "w:trPrChange", "w:tblGridChange")
36
+
37
+
38
+ def now_iso():
39
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
40
+
41
+
42
+ def halt(msg, code=2):
43
+ print(f"\nHALT: {msg}", file=sys.stderr)
44
+ sys.exit(code)
45
+
46
+
47
+ def norm(s):
48
+ """Collapse interior whitespace runs for matching only; edges are kept (never written back)."""
49
+ return re.sub(r"\s+", " ", s.replace(" ", " "))
50
+
51
+
52
+ def kids(node, *tags):
53
+ return [c for c in node.childNodes if c.nodeType == 1 and (not tags or c.tagName in tags)]
54
+
55
+
56
+ def ancestor(node, tag, stop="w:p"):
57
+ p = node.parentNode
58
+ while p is not None and p.nodeType == 1 and p.tagName != stop:
59
+ if p.tagName == tag:
60
+ return p
61
+ p = p.parentNode
62
+ return None
63
+
64
+
65
+ def unwrap(e):
66
+ par = e.parentNode
67
+ for c in list(e.childNodes):
68
+ par.insertBefore(c, e)
69
+ par.removeChild(e)
70
+
71
+
72
+ def t_text(t):
73
+ s = "".join(c.data for c in t.childNodes if c.nodeType == c.TEXT_NODE)
74
+ return s if t.getAttributeNS(XML_NS, "space") == "preserve" else s.strip(" \t\r\n")
75
+
76
+
77
+ def el_text(e, deleted=False):
78
+ return "".join(t_text(t) for t in e.getElementsByTagName("w:delText" if deleted else "w:t"))
79
+
80
+
81
+ # ---------------------------------------------------------------- document model
82
+ class Para:
83
+ def __init__(self, pid, node, in_table):
84
+ self.id, self.node, self.in_table = pid, node, in_table
85
+
86
+ def runs(self):
87
+ out = []
88
+ for c in kids(self.node):
89
+ if c.tagName == "w:r":
90
+ out.append(c)
91
+ elif c.tagName in ("w:ins", "w:del", "w:hyperlink", "w:smartTag"):
92
+ out.extend(c.getElementsByTagName("w:r"))
93
+ return out
94
+
95
+ def text(self):
96
+ parts = []
97
+ for r in self.runs():
98
+ if ancestor(r, "w:del") is not None:
99
+ continue
100
+ for t in kids(r):
101
+ if t.tagName == "w:t":
102
+ parts.append(t_text(t))
103
+ elif t.tagName == "w:tab":
104
+ parts.append("\t")
105
+ elif t.tagName in ("w:br", "w:cr"):
106
+ parts.append("\n")
107
+ return "".join(parts)
108
+
109
+ def changes(self):
110
+ out = []
111
+ for tag in ("w:ins", "w:del"):
112
+ for el in self.node.getElementsByTagName(tag):
113
+ mark = el.parentNode.tagName == "w:rPr"
114
+ txt = "(paragraph mark)" if mark else (el_text(el) or el_text(el, True))
115
+ out.append((tag[2:].upper(), el.getAttribute("w:id"), el.getAttribute("w:author") or "?", txt))
116
+ return out
117
+
118
+
119
+ def load_doc(unpacked):
120
+ dom = defusedxml.minidom.parse(str(unpacked / "word" / "document.xml"))
121
+ body = dom.getElementsByTagName("w:body")[0]
122
+ paras, counts, tbk = [], {"p": 0, "t": 0, "tb": 0}, {}
123
+
124
+ def walk(node, in_table, tpos):
125
+ for child in kids(node):
126
+ tag = child.tagName
127
+ if tag == "w:p":
128
+ if tpos and tpos[0] == "TB":
129
+ tbk[tpos[1]] = tbk.get(tpos[1], 0) + 1
130
+ pid = f"TB{tpos[1]}" + (f"P{tbk[tpos[1]]}" if tbk[tpos[1]] > 1 else "")
131
+ elif in_table and tpos:
132
+ t, r, c, k = tpos
133
+ pid = f"T{t}R{r}C{c}" + (f"P{k}" if k > 0 else "")
134
+ tpos = (t, r, c, k + 1)
135
+ else:
136
+ counts["p"] += 1
137
+ pid = f"P{counts['p']:03d}"
138
+ paras.append(Para(pid, child, in_table))
139
+ walk(child, in_table, tpos)
140
+ elif tag == "w:tbl":
141
+ counts["t"] += 1
142
+ for ri, row in enumerate(kids(child, "w:tr"), 1):
143
+ for ci, cell in enumerate(kids(row, "w:tc"), 1):
144
+ walk(cell, True, (counts["t"], ri, ci, 0))
145
+ elif tag == "w:txbxContent":
146
+ counts["tb"] += 1
147
+ walk(child, False, ("TB", counts["tb"]))
148
+ elif tag in ("mc:Fallback", "w:sectPr"):
149
+ continue
150
+ elif tag in ("w:sdt", "w:sdtContent", "w:customXml", "w:smartTag", "mc:AlternateContent", "mc:Choice",
151
+ "w:drawing", "w:pict", "wp:anchor", "wp:inline", "a:graphic", "a:graphicData", "wps:wsp",
152
+ "wps:txbx", "v:shape", "v:textbox", "w:r"):
153
+ walk(child, in_table, tpos)
154
+
155
+ walk(body, False, None)
156
+ return dom, paras
157
+
158
+
159
+ def doc_para_count(unpacked):
160
+ raw = (unpacked / "word" / "document.xml").read_text(encoding="utf-8", errors="replace")
161
+ body = re.search(r"<w:body>(.*)</w:body>", raw, flags=re.S)
162
+ if not body:
163
+ return -1
164
+ return len(re.findall(r"<w:p[\s>]", re.sub(r"<mc:Fallback>.*?</mc:Fallback>", "", body.group(1), flags=re.S)))
165
+
166
+
167
+ def unpack(docx, dest, merge=True):
168
+ dest.mkdir()
169
+ with zipfile.ZipFile(docx) as zf:
170
+ safe_extract(zf, dest)
171
+ if merge:
172
+ subprocess.run([sys.executable, str(DOCX_SKILL / "merge_runs.py"), str(dest)], check=True, capture_output=True)
173
+
174
+
175
+ # ---------------------------------------------------------------- comment threads (satellite parts, minidom)
176
+ def comment_maps(unpacked):
177
+ """id -> (author, text, last paraId); paraId -> (parent paraId, done)."""
178
+ cpath, epath = unpacked / "word" / "comments.xml", unpacked / "word" / "commentsExtended.xml"
179
+ cm, ext = {}, {}
180
+ if cpath.exists():
181
+ for c in defusedxml.minidom.parse(str(cpath)).getElementsByTagName("w:comment"):
182
+ ps = c.getElementsByTagName("w:p")
183
+ cm[int(c.getAttribute("w:id"))] = (c.getAttribute("w:author"), " / ".join(el_text(p) for p in ps),
184
+ ps[-1].getAttribute("w14:paraId") if ps else "")
185
+ if epath.exists():
186
+ for x in defusedxml.minidom.parse(str(epath)).getElementsByTagName("w15:commentEx"):
187
+ ext[x.getAttribute("w15:paraId")] = (x.getAttribute("w15:paraIdParent"), x.getAttribute("w15:done"))
188
+ return cm, ext
189
+
190
+
191
+ def thread(unpacked, cid):
192
+ cm, ext = comment_maps(unpacked)
193
+ if cid not in cm:
194
+ halt(f"comment {cid} does not exist (extract lists the comments).")
195
+ root_pid = ext.get(cm[cid][2], ("", ""))[0] or cm[cid][2]
196
+ root = next((i for i, v in cm.items() if v[2] == root_pid), cid)
197
+ members = [root] + sorted(i for i, v in cm.items() if i != root and ext.get(v[2], ("", ""))[0] == root_pid)
198
+ return root, root_pid, members
199
+
200
+
201
+ def ext_edit(unpacked, fn):
202
+ epath = unpacked / "word" / "commentsExtended.xml"
203
+ if not epath.exists():
204
+ halt("the document has no commentsExtended.xml; threads cannot be edited.")
205
+ edom = defusedxml.minidom.parse(str(epath))
206
+ n = sum(1 for x in edom.getElementsByTagName("w15:commentEx") if fn(x))
207
+ epath.write_bytes(edom.toxml(encoding="UTF-8"))
208
+ return n
209
+
210
+
211
+ def ensure_people(unpacked, author):
212
+ """Ensure people.xml exists and contains an entry for the given author. Word 365 requires this."""
213
+ ppath = unpacked / "word" / "people.xml"
214
+ tpl = Path(os.environ.get("DOCX_SKILL", "/mnt/skills/public/docx/scripts")) / "templates" / "people.xml"
215
+ if not ppath.exists():
216
+ if tpl.exists():
217
+ import shutil
218
+ shutil.copy(tpl, ppath)
219
+ else:
220
+ ppath.write_text('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
221
+ '<w15:people xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">'
222
+ '</w15:people>', encoding="utf-8")
223
+ # add relationship and content type
224
+ rels_path = unpacked / "word" / "_rels" / "document.xml.rels"
225
+ if rels_path.exists():
226
+ rdom = defusedxml.minidom.parse(str(rels_path))
227
+ has_people = any(r.getAttribute("Target") == "people.xml" for r in rdom.getElementsByTagName("Relationship"))
228
+ if not has_people:
229
+ used = {int(r.getAttribute("Id")[3:]) for r in rdom.getElementsByTagName("Relationship")
230
+ if r.getAttribute("Id").startswith("rId") and r.getAttribute("Id")[3:].isdigit()}
231
+ nid = (max(used) + 1) if used else 100
232
+ rel = rdom.createElement("Relationship")
233
+ rel.setAttribute("Id", f"rId{nid}")
234
+ rel.setAttribute("Type", "http://schemas.microsoft.com/office/2011/relationships/people")
235
+ rel.setAttribute("Target", "people.xml")
236
+ rdom.documentElement.appendChild(rel)
237
+ rels_path.write_bytes(rdom.toxml(encoding="UTF-8"))
238
+ ct_path = unpacked / "[Content_Types].xml"
239
+ if ct_path.exists():
240
+ cdom = defusedxml.minidom.parse(str(ct_path))
241
+ has_ct = any(o.getAttribute("PartName") == "/word/people.xml" for o in cdom.getElementsByTagName("Override"))
242
+ if not has_ct:
243
+ ov = cdom.createElement("Override")
244
+ ov.setAttribute("PartName", "/word/people.xml")
245
+ ov.setAttribute("ContentType", "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml")
246
+ cdom.documentElement.appendChild(ov)
247
+ ct_path.write_bytes(cdom.toxml(encoding="UTF-8"))
248
+ # ensure the author has a person entry
249
+ pdom = defusedxml.minidom.parse(str(ppath))
250
+ for p in pdom.getElementsByTagName("w15:person"):
251
+ if p.getAttribute("w15:author") == author:
252
+ return # already present
253
+ ns_w15 = "http://schemas.microsoft.com/office/word/2012/wordml"
254
+ person = pdom.createElementNS(ns_w15, "w15:person")
255
+ person.setAttribute("w15:author", author)
256
+ pi = pdom.createElementNS(ns_w15, "w15:presenceInfo")
257
+ pi.setAttribute("w15:providerId", "None")
258
+ pi.setAttribute("w15:userId", author)
259
+ person.appendChild(pi)
260
+ pdom.documentElement.appendChild(person)
261
+ ppath.write_bytes(pdom.toxml(encoding="UTF-8"))
262
+
263
+
264
+ def ref_run(dom, cid):
265
+ r, rpr, st = dom.createElement("w:r"), dom.createElement("w:rPr"), dom.createElement("w:rStyle")
266
+ st.setAttribute("w:val", "CommentReference")
267
+ rpr.appendChild(st)
268
+ r.appendChild(rpr)
269
+ ref = dom.createElement("w:commentReference")
270
+ ref.setAttribute("w:id", str(cid))
271
+ r.appendChild(ref)
272
+ return r
273
+
274
+
275
+ def marker(dom, tag, cid):
276
+ m = dom.createElement(tag)
277
+ m.setAttribute("w:id", str(cid))
278
+ return m
279
+
280
+
281
+ def op_reply(dom, unpacked, parent, text):
282
+ """Reply inside an existing thread, anchored after the thread's last existing reply."""
283
+ root, root_pid, members = thread(unpacked, parent)
284
+ cid, pid, _ = docx_comment.add_comment(unpacked, text, author=AUTHOR, initials=INITIALS)
285
+ ensure_people(unpacked, AUTHOR)
286
+ ext_edit(unpacked, lambda x: x.getAttribute("w15:paraId") == pid and (x.setAttribute("w15:paraIdParent", root_pid) or True))
287
+ refs = [e for e in dom.getElementsByTagName("w:commentReference") if int(e.getAttribute("w:id")) in members]
288
+ if not refs:
289
+ halt(f"thread {root}: no commentReference run in the body.")
290
+ last_run = refs[-1].parentNode
291
+ new_run = ref_run(dom, cid)
292
+ last_run.parentNode.insertBefore(new_run, last_run.nextSibling)
293
+ starts = [e for e in dom.getElementsByTagName("w:commentRangeStart") if int(e.getAttribute("w:id")) in members]
294
+ ends = [e for e in dom.getElementsByTagName("w:commentRangeEnd") if int(e.getAttribute("w:id")) in members]
295
+ rs, re_ = marker(dom, "w:commentRangeStart", cid), marker(dom, "w:commentRangeEnd", cid)
296
+ (starts[-1].parentNode.insertBefore(rs, starts[-1].nextSibling) if starts
297
+ else last_run.parentNode.insertBefore(rs, last_run))
298
+ (ends[-1].parentNode.insertBefore(re_, ends[-1].nextSibling) if ends
299
+ else new_run.parentNode.insertBefore(re_, new_run))
300
+ return cid, root, int(refs[-1].getAttribute("w:id"))
301
+
302
+
303
+ def op_resolve(unpacked, parent):
304
+ root, root_pid, members = thread(unpacked, parent)
305
+ n = ext_edit(unpacked, lambda x: (x.getAttribute("w15:paraId") == root_pid or x.getAttribute("w15:paraIdParent") == root_pid)
306
+ and (x.setAttribute("w15:done", "1") or True))
307
+ return root, n
308
+
309
+
310
+ # ---------------------------------------------------------------- accept (round-2 step and rule 6)
311
+ def rev_ok(e, keep, ids):
312
+ i = e.getAttribute("w:id")
313
+ return e.getAttribute("w:author") not in keep and (ids is None or (i.isdigit() and int(i) in ids))
314
+
315
+
316
+ def accept_dom(dom, keep, ids=None):
317
+ """Accept tracked changes by authors not in keep (and, when ids is given, only those ids). Comment and
318
+ bookmark markers inside deleted runs survive; a deleted paragraph mark merges into the next paragraph."""
319
+ n, merge = 0, []
320
+ for tag in PR_CHANGES:
321
+ for e in list(dom.getElementsByTagName(tag)):
322
+ if rev_ok(e, keep, ids):
323
+ e.parentNode.removeChild(e)
324
+ n += 1
325
+ for e in list(dom.getElementsByTagName("w:ins")):
326
+ if e.parentNode.tagName == "w:rPr" and rev_ok(e, keep, ids):
327
+ e.parentNode.removeChild(e)
328
+ n += 1
329
+ for e in list(dom.getElementsByTagName("w:del")):
330
+ if e.parentNode.tagName == "w:rPr" and rev_ok(e, keep, ids):
331
+ p = e.parentNode.parentNode.parentNode
332
+ e.parentNode.removeChild(e)
333
+ n += 1
334
+ if p.tagName == "w:p":
335
+ merge.append(p)
336
+ for e in list(dom.getElementsByTagName("w:del")):
337
+ if e.parentNode is None or e.parentNode.tagName == "w:rPr" or not rev_ok(e, keep, ids):
338
+ continue
339
+ par = e.parentNode
340
+ for c in kids(e):
341
+ if c.tagName in MARKERS or c.getElementsByTagName("w:commentReference"):
342
+ if c.tagName == "w:r":
343
+ for t in kids(c):
344
+ if t.tagName not in ("w:rPr", "w:commentReference"):
345
+ c.removeChild(t)
346
+ par.insertBefore(c, e)
347
+ par.removeChild(e)
348
+ n += 1
349
+ for e in list(dom.getElementsByTagName("w:ins")):
350
+ if e.parentNode is None or e.parentNode.tagName == "w:rPr" or not rev_ok(e, keep, ids):
351
+ continue
352
+ unwrap(e)
353
+ n += 1
354
+ for p in merge:
355
+ q = p.nextSibling
356
+ while q is not None and q.nodeType != 1:
357
+ q = q.nextSibling
358
+ if q is None or q.tagName != "w:p":
359
+ continue
360
+ qp = kids(q, "w:pPr")
361
+ anchor = qp[0].nextSibling if qp else q.firstChild
362
+ for c in kids(p):
363
+ if c.tagName != "w:pPr":
364
+ q.insertBefore(c, anchor)
365
+ p.parentNode.removeChild(p)
366
+ return n
367
+
368
+
369
+ def revisions(dom):
370
+ out = {}
371
+ for tag in ("w:ins", "w:del"):
372
+ for e in dom.getElementsByTagName(tag):
373
+ k = (tag[2:], e.getAttribute("w:author"))
374
+ out[k] = out.get(k, 0) + 1
375
+ return out
376
+
377
+
378
+ def cmd_accept(src, dst, keep, ids, quiet=False):
379
+ with zipfile.ZipFile(src) as zin:
380
+ infos = zin.infolist()
381
+ data = {i.filename: zin.read(i.filename) for i in infos}
382
+ dom = defusedxml.minidom.parseString(data["word/document.xml"])
383
+ before = revisions(dom)
384
+ n = accept_dom(dom, keep, ids)
385
+ data["word/document.xml"] = dom.toxml(encoding="UTF-8")
386
+ with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
387
+ for i in infos:
388
+ zout.writestr(i, data[i.filename])
389
+ if quiet:
390
+ return
391
+ print(f"accepted {n} tracked changes (kept authors: {', '.join(sorted(keep))}"
392
+ f"{'; ids ' + ','.join(map(str, sorted(ids))) if ids else ''})")
393
+ print(f"revisions before: {before}\nrevisions after: {revisions(dom)}\nwrote {dst}")
394
+
395
+
396
+ # ---------------------------------------------------------------- extract
397
+ def cmd_extract(docx):
398
+ with tempfile.TemporaryDirectory() as td:
399
+ unpacked = Path(td) / "u"
400
+ unpack(docx, unpacked)
401
+ dom, paras = load_doc(unpacked)
402
+ indep = doc_para_count(unpacked)
403
+ cm, ext = comment_maps(unpacked)
404
+ existing = []
405
+ print(f"# {docx.name}\n# {len(paras)} addressable blocks\n")
406
+ for p in paras:
407
+ existing += [(p.id,) + c for c in p.changes()]
408
+ print(f"[{p.id}] {p.text().replace(chr(10), ' / ').replace(chr(9), ' | ')}")
409
+ print()
410
+ if existing:
411
+ print("# EXISTING TRACKED CHANGES (read before editing; accept takes the ids):")
412
+ for pid, kind, rid, author, txt in existing:
413
+ print(f"# {pid} {kind} id={rid} by {author}: {txt[:120]!r}")
414
+ print()
415
+ if cm:
416
+ print("# COMMENTS (reply and resolve take the id):")
417
+ for i, (author, txt, pid) in sorted(cm.items()):
418
+ par, done = ext.get(pid, ("", ""))
419
+ root = next((j for j, v in cm.items() if v[2] == par), "?") if par else None
420
+ kind = f"reply to C{root}" if par else "thread root" + (", resolved" if done == "1" else ", open")
421
+ print(f"# C{i} by {author} ({kind}): {txt[:120]!r}")
422
+ print()
423
+ status = "OK" if indep == len(paras) else "MISMATCH"
424
+ print(f"# STRUCTURE: <w:p> in body={indep} walked={len(paras)} [{status}]")
425
+ if status == "MISMATCH":
426
+ halt("the walker did not visit every paragraph in the body. A container type was skipped. "
427
+ "Do not proceed until this is understood.")
428
+
429
+
430
+ # ---------------------------------------------------------------- apply
431
+ class RevIds:
432
+ def __init__(self, dom):
433
+ used = {int(e.getAttribute("w:id")) for tag in ("w:ins", "w:del", "w:commentRangeStart", "w:bookmarkStart")
434
+ for e in dom.getElementsByTagName(tag) if e.getAttribute("w:id").isdigit()}
435
+ self.next = (max(used) + 1) if used else 1000
436
+
437
+ def take(self):
438
+ self.next += 1
439
+ return str(self.next - 1)
440
+
441
+
442
+ def make_run(dom, text, rpr, deleted):
443
+ r = dom.createElement("w:r")
444
+ if rpr is not None:
445
+ r.appendChild(rpr.cloneNode(True))
446
+ t = dom.createElement("w:delText" if deleted else "w:t")
447
+ t.setAttributeNS(XML_NS, "xml:space", "preserve")
448
+ t.appendChild(dom.createTextNode(text))
449
+ r.appendChild(t)
450
+ return r
451
+
452
+
453
+ def rpr_of(run):
454
+ k = kids(run, "w:rPr")
455
+ return k[0] if k else None
456
+
457
+
458
+ def wrap(dom, tag, ids, *runs):
459
+ w = dom.createElement(tag)
460
+ w.setAttribute("w:id", ids.take())
461
+ w.setAttribute("w:author", AUTHOR)
462
+ w.setAttribute("w:date", now_iso())
463
+ for r in runs:
464
+ w.appendChild(r)
465
+ return w
466
+
467
+
468
+ def norm_to_raw(raw, nidx):
469
+ i = n = 0
470
+ while i < len(raw) and n < nidx:
471
+ if raw[i] in WS:
472
+ while i < len(raw) and raw[i] in WS:
473
+ i += 1
474
+ else:
475
+ i += 1
476
+ n += 1
477
+ return i
478
+
479
+
480
+ def locate(para, needle, label):
481
+ hay = para.text()
482
+ hn, nn = norm(hay), norm(needle)
483
+ if not nn.strip():
484
+ halt(f"{para.id}: empty '{label}' string.")
485
+ count = hn.count(nn)
486
+ if count != 1:
487
+ halt(f"{para.id}: '{label}' {'not found' if count == 0 else f'matches {count} times; widen it so it is unique'}."
488
+ f"\n looking for: {needle!r}\n paragraph reads: {hay!r}")
489
+ nidx = hn.find(nn)
490
+ return norm_to_raw(hay, nidx), norm_to_raw(hay, nidx + len(nn))
491
+
492
+
493
+ def run_text(r):
494
+ return "".join(t_text(c) if c.tagName == "w:t" else ("\t" if c.tagName == "w:tab" else "\n")
495
+ for c in kids(r, "w:t", "w:tab", "w:br", "w:cr"))
496
+
497
+
498
+ def decompose_runs(dom, para):
499
+ """Split any run with mixed w:t/w:tab/w:br/w:cr children into one run per child."""
500
+ for r in list(para.runs()):
501
+ if ancestor(r, "w:del") is not None:
502
+ continue
503
+ content = kids(r, "w:t", "w:tab", "w:br", "w:cr")
504
+ if len(content) <= 1:
505
+ continue
506
+ rpr = rpr_of(r)
507
+ parent = r.parentNode
508
+ for c in content:
509
+ new_r = dom.createElement("w:r")
510
+ if rpr is not None:
511
+ new_r.appendChild(rpr.cloneNode(True))
512
+ new_r.appendChild(c)
513
+ parent.insertBefore(new_r, r)
514
+ parent.removeChild(r)
515
+
516
+
517
+ def split_at(dom, para, offsets):
518
+ decompose_runs(dom, para)
519
+ for target in sorted(set(offsets)):
520
+ pos = 0
521
+ for r in para.runs():
522
+ if ancestor(r, "w:del") is not None:
523
+ continue
524
+ rtext = run_text(r)
525
+ if pos < target < pos + len(rtext):
526
+ ts = kids(r, "w:t", "w:tab", "w:br", "w:cr")
527
+ if len(ts) != 1 or ts[0].tagName != "w:t":
528
+ halt(f"{para.id}: cannot split a run containing non-text elements at offset {target}.")
529
+ full, cut = t_text(ts[0]), target - pos
530
+ r.parentNode.insertBefore(make_run(dom, full[:cut], rpr_of(r), False), r)
531
+ r.parentNode.insertBefore(make_run(dom, full[cut:], rpr_of(r), False), r)
532
+ r.parentNode.removeChild(r)
533
+ break
534
+ pos += len(rtext)
535
+
536
+
537
+ def runs_in_span(para, start, end):
538
+ out, pos = [], 0
539
+ for r in para.runs():
540
+ if ancestor(r, "w:del") is not None:
541
+ continue
542
+ rtext = run_text(r)
543
+ if pos >= start and pos + len(rtext) <= end and rtext:
544
+ out.append(r)
545
+ pos += len(rtext)
546
+ return out
547
+
548
+
549
+ def to_deleted(dom, run):
550
+ r = run.cloneNode(True)
551
+ for t in list(r.getElementsByTagName("w:t")):
552
+ d = dom.createElement("w:delText")
553
+ d.setAttributeNS(XML_NS, "xml:space", "preserve")
554
+ for c in list(t.childNodes):
555
+ d.appendChild(c)
556
+ t.parentNode.replaceChild(d, t)
557
+ return r
558
+
559
+
560
+ def guard(para, runs, accepted):
561
+ """Rule 6 and the cross-author refusal: a find range inside another author's still-tracked insertion
562
+ accepts that insertion first; a range crossing from one author's insertion into another's stops."""
563
+ owners, plain = {}, False
564
+ for r in runs:
565
+ e = ancestor(r, "w:ins")
566
+ if e is None:
567
+ plain = True
568
+ else:
569
+ owners.setdefault(e.getAttribute("w:author"), []).append(e)
570
+ names = list(owners)
571
+ if len(names) > 1:
572
+ halt(f"{para.id}: find range crosses from {names[0]}'s insertion into {names[1]}'s. "
573
+ "Split the edit at the boundary.")
574
+ if names and names[0] != AUTHOR:
575
+ for e in dict.fromkeys(owners[names[0]]):
576
+ rid = e.getAttribute("w:id")
577
+ unwrap(e)
578
+ accepted.append((rid, names[0]))
579
+ print(f"# {para.id}: accepted insertion id={rid} by {names[0]} before editing inside it (rule 6)")
580
+ elif names and plain:
581
+ halt(f"{para.id}: find range crosses from your own insertion into base text. Split the edit.")
582
+
583
+
584
+ def span(dom, para, find, label, accepted):
585
+ start, end = locate(para, find, label)
586
+ split_at(dom, para, [start, end])
587
+ runs = runs_in_span(para, start, end)
588
+ if not runs:
589
+ halt(f"{para.id}: internal: no runs in span after split.")
590
+ if accepted is not None:
591
+ guard(para, runs, accepted)
592
+ return runs
593
+
594
+
595
+ def op_replace_or_delete(dom, para, ids, find, with_, accepted):
596
+ runs = span(dom, para, find, "find", accepted)
597
+ rpr, anchor = rpr_of(runs[0]), runs[-1]
598
+ parent, nxt = anchor.parentNode, anchor.nextSibling
599
+ deleted = [to_deleted(dom, r) for r in runs]
600
+ for r in runs:
601
+ r.parentNode.removeChild(r)
602
+ del_wrap = wrap(dom, "w:del", ids, *deleted)
603
+ parent.insertBefore(del_wrap, nxt)
604
+ if with_ is not None:
605
+ parent.insertBefore(wrap(dom, "w:ins", ids, make_run(dom, with_, rpr, False)), del_wrap.nextSibling)
606
+
607
+
608
+ def op_insert_after(dom, para, ids, find, text, accepted):
609
+ anchor = span(dom, para, find, "find", accepted)[-1]
610
+ anchor.parentNode.insertBefore(wrap(dom, "w:ins", ids, make_run(dom, text, rpr_of(anchor), False)), anchor.nextSibling)
611
+
612
+
613
+ def para_mark(dom, para_node, ids, tag):
614
+ ppr = kids(para_node, "w:pPr")
615
+ if not ppr:
616
+ ppr = [dom.createElement("w:pPr")]
617
+ para_node.insertBefore(ppr[0], para_node.firstChild)
618
+ rpr = kids(ppr[0], "w:rPr")
619
+ if not rpr:
620
+ rpr = [dom.createElement("w:rPr")]
621
+ ppr[0].appendChild(rpr[0])
622
+ rpr[0].insertBefore(wrap(dom, tag, ids), rpr[0].firstChild)
623
+
624
+
625
+ def op_insert_paragraph_after(dom, para, ids, text):
626
+ newp = dom.createElement("w:p")
627
+ src = kids(para.node, "w:pPr")
628
+ newp.appendChild(src[0].cloneNode(True) if src else dom.createElement("w:pPr"))
629
+ para_mark(dom, newp, ids, "w:ins")
630
+ rs = para.runs()
631
+ newp.appendChild(wrap(dom, "w:ins", ids, make_run(dom, text, rpr_of(rs[0]) if rs else None, False)))
632
+ para.node.parentNode.insertBefore(newp, para.node.nextSibling)
633
+
634
+
635
+ def op_delete_paragraph(dom, para, ids, accepted):
636
+ for r in para.runs():
637
+ e = ancestor(r, "w:ins")
638
+ if e is not None and e.getAttribute("w:author") != AUTHOR and e.parentNode is not None:
639
+ accepted.append((e.getAttribute("w:id"), e.getAttribute("w:author")))
640
+ print(f"# {para.id}: accepted insertion id={e.getAttribute('w:id')} by {e.getAttribute('w:author')} (rule 6)")
641
+ unwrap(e)
642
+ for r in para.runs():
643
+ if ancestor(r, "w:del") is None:
644
+ r.parentNode.replaceChild(wrap(dom, "w:del", ids, to_deleted(dom, r)), r)
645
+ para_mark(dom, para.node, ids, "w:del")
646
+
647
+
648
+ def op_comment(dom, para, anchor, text, unpacked):
649
+ runs = span(dom, para, anchor, "anchor", None)
650
+ cid, _, _ = docx_comment.add_comment(unpacked, text, author=AUTHOR, initials=INITIALS)
651
+ ensure_people(unpacked, AUTHOR)
652
+ rs, re_ = marker(dom, "w:commentRangeStart", cid), marker(dom, "w:commentRangeEnd", cid)
653
+ runs[0].parentNode.insertBefore(rs, runs[0])
654
+ runs[-1].parentNode.insertBefore(re_, runs[-1].nextSibling)
655
+ re_.parentNode.insertBefore(ref_run(dom, cid), re_.nextSibling)
656
+ return cid
657
+
658
+
659
+ def render_marked(before, after):
660
+ import difflib
661
+ a, b, out = before.split(), after.split(), []
662
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
663
+ if tag == "equal":
664
+ out.extend(a[i1:i2])
665
+ else:
666
+ if tag != "insert":
667
+ out.append("~~" + " ".join(a[i1:i2]) + "~~")
668
+ if tag != "delete":
669
+ out.append("**" + " ".join(b[j1:j2]) + "**")
670
+ return " ".join(out)
671
+
672
+
673
+ def check_plan(plan):
674
+ edits = plan.get("edits")
675
+ if not isinstance(edits, list) or not edits:
676
+ halt("plan.json has no 'edits' list.")
677
+ need = {"replace": ("id", "find", "with"), "insert_after": ("id", "find", "text"), "delete": ("id", "find"),
678
+ "insert_paragraph_after": ("id", "text"), "delete_paragraph": ("id",), "comment": ("id", "anchor", "text"),
679
+ "reply": ("parent", "text"), "resolve": ("parent",)}
680
+ for i, e in enumerate(edits):
681
+ if e.get("op") not in OPS:
682
+ halt(f"edit #{i}: unknown op {e.get('op')!r}. Allowed: {sorted(OPS)}")
683
+ for k in need[e["op"]]:
684
+ if k not in e:
685
+ halt(f"edit #{i}: {e['op']} needs {need[e['op']]}.")
686
+ for k in e:
687
+ if k not in KEYS:
688
+ halt(f"edit #{i}: unexpected key {k!r}. Full-paragraph rewrites are not an op; use find/replace.")
689
+ return edits
690
+
691
+
692
+ def cmd_apply(docx, plan_path, out, report):
693
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
694
+ edits = check_plan(plan)
695
+ n_text = sum(1 for e in edits if e["op"] in TEXT_OPS)
696
+ n_comment = sum(1 for e in edits if e["op"] == "comment" and not (e.get("question") or plan.get("questions")))
697
+ if n_comment > n_text:
698
+ halt(f"BUILD GATE: {n_comment} comments against {n_text} tracked changes. Comments are for business "
699
+ 'decisions; redline the rest, or mark the comments as questions ("question": true on the edit, '
700
+ 'or "questions": true on the plan).')
701
+ with tempfile.TemporaryDirectory() as td:
702
+ unpacked = Path(td) / "u"
703
+ unpack(docx, unpacked)
704
+ dom, paras = load_doc(unpacked)
705
+ by_id = {p.id: p for p in paras}
706
+ before = {p.id: p.text() for p in paras}
707
+ ids, touched, accepted, notes = RevIds(dom), [], [], []
708
+ for i, e in enumerate(edits):
709
+ op = e["op"]
710
+ if op in ("reply", "resolve"):
711
+ if op == "reply":
712
+ cid, root, after = op_reply(dom, unpacked, int(e["parent"]), e["text"])
713
+ notes.append(f"## reply C{cid} in thread C{root}, placed after C{after}\nREPLY: {e['text']}")
714
+ else:
715
+ root, n = op_resolve(unpacked, int(e["parent"]))
716
+ notes.append(f"## resolved thread C{root} ({n} entries marked done)")
717
+ continue
718
+ p = by_id.get(e["id"])
719
+ if p is None:
720
+ halt(f"edit #{i}: paragraph {e['id']} does not exist. Run extract again.")
721
+ if op == "replace":
722
+ op_replace_or_delete(dom, p, ids, e["find"], e["with"], accepted)
723
+ elif op == "delete":
724
+ op_replace_or_delete(dom, p, ids, e["find"], None, accepted)
725
+ elif op == "insert_after":
726
+ op_insert_after(dom, p, ids, e["find"], e["text"], accepted)
727
+ elif op == "insert_paragraph_after":
728
+ op_insert_paragraph_after(dom, p, ids, e["text"])
729
+ notes.append(f"## new paragraph after {p.id}\nINSERTED: {e['text']}")
730
+ elif op == "delete_paragraph":
731
+ op_delete_paragraph(dom, p, ids, accepted)
732
+ else:
733
+ cid = op_comment(dom, p, e["anchor"], e["text"], unpacked)
734
+ notes.append(f"## comment C{cid} on {p.id}{' (question)' if e.get('question') else ''}\nCOMMENT: {e['text']}")
735
+ if op != "insert_paragraph_after" and op != "comment":
736
+ touched.append(p.id)
737
+ (unpacked / "word" / "document.xml").write_bytes(dom.toxml(encoding="UTF-8"))
738
+ out.parent.mkdir(parents=True, exist_ok=True)
739
+ rezip(unpacked, out)
740
+ original = docx
741
+ if accepted: # validate against the input with the same insertions accepted (rule 6 leaves them untracked)
742
+ original = Path(td) / "validation_original.docx"
743
+ cmd_accept(docx, original, set(), {int(r) for r, _ in accepted if r.isdigit()}, quiet=True)
744
+ after = {p.id: p.text() for p in paras} # same nodes; text() is the accepted view
745
+ lines = [f"# VERIFICATION REPORT: {out.name}", ""]
746
+ for pid in dict.fromkeys(touched):
747
+ lines += [f"## {pid}", f"BEFORE: {before[pid]}", f"AFTER: {after.get(pid, '')}",
748
+ f"MARKED: {render_marked(before[pid], after.get(pid, ''))}", ""]
749
+ for n in notes:
750
+ lines += [n, ""]
751
+ if accepted:
752
+ lines += ["## accepted before editing (rule 6): " + ", ".join(f"id={r} by {a}" for r, a in accepted), ""]
753
+ report.write_text("\n".join(lines), encoding="utf-8")
754
+ print("\n".join(lines))
755
+ res = subprocess.run([sys.executable, str(DOCX_SKILL / "office" / "validate.py"), str(out),
756
+ "--original", str(original), "--author", AUTHOR], capture_output=True, text=True)
757
+ print("\n# VALIDATE:\n" + (res.stdout.strip() or "(no output)"))
758
+ if res.returncode != 0:
759
+ print(res.stderr, file=sys.stderr)
760
+ halt("validate.py failed. The redline is not safe to deliver.")
761
+ print(f"\n# OK: {out}\n# report: {report}\n")
762
+ cmd_check(out, plan_path)
763
+
764
+
765
+ # ---------------------------------------------------------------- post-build check
766
+ def cmd_check(out, plan_path):
767
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
768
+ fails = []
769
+ try:
770
+ import docx
771
+ docx.Document(str(out))
772
+ print("python-docx: loads")
773
+ except Exception as x:
774
+ fails.append(f"python-docx cannot open the output: {x}")
775
+ if shutil.which("soffice"):
776
+ from office.soffice import run_soffice
777
+ with tempfile.TemporaryDirectory() as td:
778
+ r = run_soffice(["--headless", "--convert-to", "pdf", "--outdir", td, str(out)],
779
+ capture_output=True, text=True, timeout=180)
780
+ if any(Path(td).glob("*.pdf")):
781
+ print("LibreOffice: loads")
782
+ else:
783
+ fails.append("LibreOffice headless could not open the output: " + (r.stderr or r.stdout)[-300:])
784
+ else:
785
+ print("LibreOffice: NOT RUN (soffice not found on this machine)")
786
+ with tempfile.TemporaryDirectory() as td:
787
+ u = Path(td) / "c"
788
+ unpack(out, u, merge=False)
789
+ dom, paras = load_doc(u)
790
+ cm, ext = comment_maps(u)
791
+ counts = {t: len(dom.getElementsByTagName(t)) for t in ("w:commentRangeStart", "w:commentRangeEnd", "w:commentReference")}
792
+ print(f"comments: {len(cm)} in comments.xml, {counts['w:commentRangeStart']} commentRangeStart, "
793
+ f"{counts['w:commentRangeEnd']} commentRangeEnd, {counts['w:commentReference']} commentReference")
794
+ if len(set(counts.values()) | {len(cm)}) != 1:
795
+ fails.append("comment marker counts do not match comments.xml")
796
+ rs_ids = {int(e.getAttribute("w:id")) for e in dom.getElementsByTagName("w:commentRangeStart")}
797
+ mine = {"ins": [], "del": []}
798
+ for tag in ("w:ins", "w:del"):
799
+ for e in dom.getElementsByTagName(tag):
800
+ if e.getAttribute("w:author") == AUTHOR:
801
+ mine[tag[2:]].append((el_text(e) if tag == "w:ins" else el_text(e, True), e.parentNode.tagName == "w:rPr"))
802
+
803
+ def has(kind, text, mark=False):
804
+ return any(norm(text) in norm(t) and (m or not mark) for t, m in mine[kind]) if text else any(m for _, m in mine[kind])
805
+
806
+ for i, e in enumerate(plan.get("edits", [])):
807
+ op, ok = e["op"], True
808
+ if op in ("replace", "delete"):
809
+ ok = has("del", e["find"]) and (op == "delete" or has("ins", e["with"]))
810
+ elif op == "insert_after":
811
+ ok = has("ins", e["text"])
812
+ elif op == "insert_paragraph_after":
813
+ ok = has("ins", e["text"]) and has("ins", "", True)
814
+ elif op == "delete_paragraph":
815
+ ok = has("del", "", True)
816
+ elif op == "comment":
817
+ ok = any(norm(e["text"]) in norm(v[1]) and k in rs_ids for k, v in cm.items())
818
+ elif op == "reply":
819
+ ok = any(norm(e["text"]) in norm(v[1]) and k in rs_ids and ext.get(v[2], ("", ""))[0] for k, v in cm.items())
820
+ elif op == "resolve":
821
+ root, root_pid, members = thread(u, int(e["parent"]))
822
+ ok = all(ext.get(cm[m][2], ("", "0"))[1] == "1" for m in members)
823
+ if not ok:
824
+ fails.append(f"edit #{i} ({op}{', ' + e['id'] if 'id' in e else ''}) has no matching tracked change or comment")
825
+ print("CHECK: PASS" if not fails else "CHECK: FAIL\n " + "\n ".join(fails))
826
+ if fails:
827
+ sys.exit(1)
828
+
829
+
830
+ def main():
831
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
832
+ sub = ap.add_subparsers(dest="cmd", required=True)
833
+ sub.add_parser("extract").add_argument("docx", type=Path)
834
+ ac = sub.add_parser("accept")
835
+ ac.add_argument("docx", type=Path)
836
+ ac.add_argument("out", type=Path)
837
+ ac.add_argument("--keep", default="", help='authors whose changes stay live, e.g. "Author A,Author B"')
838
+ ac.add_argument("--ids", default="", help="accept only these revision ids, e.g. 12,15")
839
+ apl = sub.add_parser("apply")
840
+ apl.add_argument("docx", type=Path)
841
+ apl.add_argument("plan", type=Path)
842
+ apl.add_argument("--out", type=Path, required=True)
843
+ apl.add_argument("--report", type=Path, default=None)
844
+ ck = sub.add_parser("check")
845
+ ck.add_argument("docx", type=Path)
846
+ ck.add_argument("plan", type=Path)
847
+ a = ap.parse_args()
848
+ if a.cmd == "extract":
849
+ cmd_extract(a.docx)
850
+ elif a.cmd == "accept":
851
+ keep = {AUTHOR} | {s.strip() for s in a.keep.split(",") if s.strip()}
852
+ ids = {int(s) for s in a.ids.split(",") if s.strip().isdigit()} or None
853
+ cmd_accept(a.docx, a.out, keep, ids)
854
+ elif a.cmd == "apply":
855
+ cmd_apply(a.docx, a.plan, a.out, a.report or a.out.with_suffix(".report.md"))
856
+ else:
857
+ cmd_check(a.docx, a.plan)
858
+
859
+
860
+ if __name__ == "__main__":
861
+ main()
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """NorthStar redline verification pair (ctx 3.4.0, rule 11).
3
+ Usage: python verify_redline.py OUT.docx ORIG.docx BASE.docx
4
+ ORIG is the counterparty document as received; BASE is ORIG after the accept step (the apply input).
5
+ REJECT: OUT with this turn's the author's changes rejected must read, paragraph for paragraph, as BASE.
6
+ REPORT: parts, revisions by author, original comments preserved, part counts, new comments, resolved and
7
+ open threads, reply order. Ends VERIFY: PASS or VERIFY: FAIL with the failing items.
8
+ """
9
+ import os, re, sys, zipfile
10
+ from xml.dom import minidom
11
+
12
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
13
+ W14 = "http://schemas.microsoft.com/office/word/2010/wordml"
14
+ W15 = "http://schemas.microsoft.com/office/word/2012/wordml"
15
+ AUTHOR = os.environ.get("CTX_AUTHOR", "").strip()
16
+ if not AUTHOR:
17
+ sys.exit("HALT: set CTX_AUTHOR (the tracked-change author name) before running.")
18
+
19
+
20
+ def text(n):
21
+ return "".join(c.data for c in n.childNodes if c.nodeType == c.TEXT_NODE)
22
+
23
+
24
+ def rev_ids(d, author=None):
25
+ return {e.getAttributeNS(W, "id") for t in ("ins", "del") for e in d.getElementsByTagNameNS(W, t)
26
+ if author is None or e.getAttributeNS(W, "author") == author}
27
+
28
+
29
+ def unwrap(e):
30
+ p = e.parentNode
31
+ for c in list(e.childNodes):
32
+ p.insertBefore(c, e)
33
+ p.removeChild(e)
34
+
35
+
36
+ def sig(xml, reject=frozenset(), accept=frozenset()):
37
+ """Per-paragraph (owner, text) segments: '=' kept, '+author' inserted, '-author' deleted. Revisions in
38
+ reject are undone (a paragraph inserted under a rejected mark is dropped); revisions in accept are accepted."""
39
+ d = minidom.parseString(xml)
40
+ for e in list(d.getElementsByTagNameNS(W, "ins")):
41
+ i, mark = e.getAttributeNS(W, "id"), e.parentNode.localName == "rPr"
42
+ if i in reject:
43
+ (e.parentNode.parentNode.parentNode.parentNode.removeChild(e.parentNode.parentNode.parentNode)
44
+ if mark else e.parentNode.removeChild(e))
45
+ elif i in accept:
46
+ e.parentNode.removeChild(e) if mark else unwrap(e)
47
+ for e in list(d.getElementsByTagNameNS(W, "del")):
48
+ i, mark = e.getAttributeNS(W, "id"), e.parentNode.localName == "rPr"
49
+ if mark and (i in reject or i in accept):
50
+ e.parentNode.removeChild(e)
51
+ elif i in reject:
52
+ for t in list(e.getElementsByTagNameNS(W, "delText")):
53
+ n = d.createElementNS(W, "w:t")
54
+ for c in list(t.childNodes):
55
+ n.appendChild(c)
56
+ t.parentNode.replaceChild(n, t)
57
+ unwrap(e)
58
+ elif i in accept:
59
+ e.parentNode.removeChild(e)
60
+ out = []
61
+ for p in d.getElementsByTagNameNS(W, "p"):
62
+ m = []
63
+ for n in p.getElementsByTagNameNS("*", "*"):
64
+ if n.localName not in ("t", "delText"):
65
+ continue
66
+ a, st = n.parentNode, "="
67
+ while a is not None and a.nodeType == 1 and a.localName != "p":
68
+ if a.localName in ("ins", "del"):
69
+ st = ("+" if a.localName == "ins" else "-") + a.getAttributeNS(W, "author")
70
+ break
71
+ a = a.parentNode
72
+ if m and m[-1][0] == st:
73
+ m[-1] = (st, m[-1][1] + text(n))
74
+ else:
75
+ m.append((st, text(n)))
76
+ out.append([s for s in m if s[1]])
77
+ return out
78
+
79
+
80
+ def comments(z):
81
+ r = {}
82
+ if "word/comments.xml" not in z.namelist():
83
+ return r
84
+ for x in minidom.parseString(z.read("word/comments.xml")).getElementsByTagNameNS(W, "comment"):
85
+ ps = x.getElementsByTagNameNS(W, "p")
86
+ txt = " / ".join("".join(text(t) for t in p.getElementsByTagNameNS(W, "t")) for p in ps)
87
+ r[int(x.getAttributeNS(W, "id"))] = (x.getAttributeNS(W, "author"), txt, ps[-1].getAttributeNS(W14, "paraId"))
88
+ return r
89
+
90
+
91
+ def ext(z):
92
+ if "word/commentsExtended.xml" not in z.namelist():
93
+ return {}
94
+ return {x.getAttributeNS(W15, "paraId"): (x.getAttributeNS(W15, "paraIdParent"), x.getAttributeNS(W15, "done"))
95
+ for x in minidom.parseString(z.read("word/commentsExtended.xml")).getElementsByTagNameNS(W15, "commentEx")}
96
+
97
+
98
+ def main(out, orig, base):
99
+ zo, zg, zb = zipfile.ZipFile(out), zipfile.ZipFile(orig), zipfile.ZipFile(base)
100
+ fails = []
101
+ print("=====", out)
102
+ ob, bb = zo.read("word/document.xml"), zb.read("word/document.xml")
103
+ do, db = minidom.parseString(ob), minidom.parseString(bb)
104
+ new_ids = rev_ids(do, AUTHOR) - rev_ids(db, AUTHOR)
105
+ gone = rev_ids(db) - rev_ids(do) # base revisions the build accepted before editing inside them (rule 6)
106
+ a, b = sig(ob, reject=new_ids), sig(bb, accept=gone)
107
+ bad = [i for i, (x, y) in enumerate(zip(a, b)) if x != y]
108
+ if len(a) != len(b):
109
+ bad.append(f"paragraph count {len(a)} vs {len(b)}")
110
+ print(f"REJECT: this turn's {len(new_ids)} {AUTHOR} revisions rejected ({len(gone)} base revisions accepted "
111
+ f"under rule 6); paragraphs {len(a)} vs base {len(b)}; mismatched: {bad[:20]}")
112
+ for i in [i for i in bad if isinstance(i, int)][:5]:
113
+ print(" OUT ", a[i], "\n BASE", b[i])
114
+ if bad:
115
+ fails.append("REJECT: output with the author's changes rejected does not read as the base")
116
+ exp = {"word/document.xml", "word/comments.xml", "word/commentsExtended.xml", "word/commentsIds.xml",
117
+ "word/commentsExtensible.xml", "word/people.xml", "[Content_Types].xml", "word/_rels/document.xml.rels"}
118
+ changed = [n for n in zg.namelist() if n not in exp and (n not in zo.namelist() or zg.read(n) != zo.read(n))]
119
+ missing = [n for n in zg.namelist() if n not in zo.namelist()]
120
+ print("1 other parts changed vs counterparty original:", changed or "none", "| missing:", missing or "none")
121
+ if changed or missing:
122
+ fails.append("1 package parts changed or missing")
123
+ d = minidom.parseString(ob)
124
+ revs = {}
125
+ for t in ("ins", "del", "rPrChange", "pPrChange", "moveFrom", "moveTo"):
126
+ for e in d.getElementsByTagNameNS(W, t):
127
+ k = (t, e.getAttributeNS(W, "author"))
128
+ revs[k] = revs.get(k, 0) + 1
129
+ print("2 revisions in output by type/author:", revs)
130
+ co, cg = comments(zo), comments(zg)
131
+ lost = [i for i in cg if i not in co or co[i][:2] != cg[i][:2]]
132
+ print("3 original comments:", len(cg), "| lost or altered:", lost or "none")
133
+ if lost:
134
+ fails.append("3 original comments lost or altered")
135
+ eo, eg = ext(zo), ext(zg)
136
+ pid2id = {v[2]: k for k, v in co.items()}
137
+ n_ids = len(re.findall(rb"<w16cid:commentId ", zo.read("word/commentsIds.xml"))) if "word/commentsIds.xml" in zo.namelist() else 0
138
+ n_ex = len(re.findall(rb"<w16cex:commentExtensible ", zo.read("word/commentsExtensible.xml"))) if "word/commentsExtensible.xml" in zo.namelist() else 0
139
+ print("4 part counts comments/ext/ids/extensible:", len(co), len(eo), n_ids, n_ex)
140
+ if co and len({len(co), len(eo), n_ids, n_ex}) != 1:
141
+ fails.append("4 comment part counts differ")
142
+ doc = ob.decode("utf-8")
143
+ for i, (au, t, p) in sorted(co.items()):
144
+ if i in cg:
145
+ continue
146
+ par = eo.get(p, ("", ""))[0]
147
+ dashes = bool(re.search("[\u2013\u2014]|--", t))
148
+ print(f"5 NEW C{i} by {au} | reply to C{pid2id.get(par, '-')} | ref {f'<w:commentReference w:id={chr(34)}{i}{chr(34)}/>' in doc} "
149
+ f"| range {f'<w:commentRangeStart w:id={chr(34)}{i}{chr(34)}/>' in doc} | dashes {dashes} | {t[:80]}")
150
+ if dashes:
151
+ fails.append(f"5 comment C{i} contains a dash")
152
+ if au != AUTHOR:
153
+ fails.append(f"5 comment C{i} is not by {AUTHOR}")
154
+ flipped = sorted({pid2id.get(eo[p][0] or p, "?") for p in eo if p in eg and eg[p][1] != eo[p][1]})
155
+ print("6 threads whose resolved state changed (root ids):", flipped)
156
+ print("7 threads still open (root ids):", sorted(pid2id[p] for p, v in eo.items() if not v[0] and v[1] != "1" and p in pid2id))
157
+ for i, (au, t, p) in co.items():
158
+ par = eo.get(p, ("", ""))[0]
159
+ if i in cg or not par:
160
+ continue
161
+ mem = [pid2id[q] for q, v in eo.items() if (v[0] == par or q == par) and q in pid2id]
162
+ pos = {m: doc.find(f'<w:commentReference w:id="{m}"/>') for m in mem}
163
+ last = max(pos, key=pos.get) == i
164
+ print(f"8 reply C{i} displays last in its thread: {last}")
165
+ if not last:
166
+ fails.append(f"8 reply C{i} is not last in its thread")
167
+ print("VERIFY: PASS" if not fails else "VERIFY: FAIL\n " + "\n ".join(fails))
168
+ sys.exit(1 if fails else 0)
169
+
170
+
171
+ def cli():
172
+ if len(sys.argv) != 4:
173
+ print(__doc__)
174
+ sys.exit(2)
175
+ main(*sys.argv[1:4])
176
+
177
+
178
+ if __name__ == "__main__":
179
+ cli()
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: northstar-ctx
3
+ Version: 3.4.0
4
+ Summary: Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: defusedxml>=0.7
10
+ Requires-Dist: python-docx>=1.1
11
+ Dynamic: license-file
12
+
13
+ # northstar-ctx
14
+
15
+ Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
16
+
17
+ export CTX_AUTHOR="Your Name"
18
+ ctx-redline extract in.docx
19
+ ctx-redline apply in.docx plan.json --out out.docx
20
+ ctx-verify out.docx in.docx base.docx
21
+
22
+ Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ northstar_ctx/__init__.py
5
+ northstar_ctx/redline_builder.py
6
+ northstar_ctx/verify_redline.py
7
+ northstar_ctx.egg-info/PKG-INFO
8
+ northstar_ctx.egg-info/SOURCES.txt
9
+ northstar_ctx.egg-info/dependency_links.txt
10
+ northstar_ctx.egg-info/entry_points.txt
11
+ northstar_ctx.egg-info/requires.txt
12
+ northstar_ctx.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ctx-redline = northstar_ctx.redline_builder:main
3
+ ctx-verify = northstar_ctx.verify_redline:cli
@@ -0,0 +1,2 @@
1
+ defusedxml>=0.7
2
+ python-docx>=1.1
@@ -0,0 +1 @@
1
+ northstar_ctx
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "northstar-ctx"
7
+ version = "3.4.0"
8
+ description = "Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ dependencies = ["defusedxml>=0.7", "python-docx>=1.1"]
13
+
14
+ [project.scripts]
15
+ ctx-redline = "northstar_ctx.redline_builder:main"
16
+ ctx-verify = "northstar_ctx.verify_redline:cli"
17
+
18
+ [tool.setuptools]
19
+ packages = ["northstar_ctx"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+