doc-control 0.0.1.dev0__py3-none-any.whl

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.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,679 @@
1
+ """The five comment parts of a package, written together so they never disagree.
2
+
3
+ ``word/comments.xml`` holds the comments; ``commentsExtended.xml`` the reply link
4
+ (``w15:paraIdParent``) and the resolved flag (``w15:done``) of each, keyed by the
5
+ ``w14:paraId`` of the comment's *last* paragraph; ``commentsIds.xml`` maps that
6
+ ``paraId`` to a ``w16cid:durableId``; ``commentsExtensible.xml`` repeats the durable id
7
+ with the creation time; ``people.xml`` lists the authors.
8
+
9
+ :class:`CommentParts` reads whichever of them exist and collects additions
10
+ (:meth:`add`) and resolved-state changes (:meth:`set_done`). :meth:`build` returns the
11
+ bytes of every part that changes or has to be created, together with the matching
12
+ ``[Content_Types].xml`` overrides and ``word/_rels/document.xml.rels`` relationships.
13
+ Existing parts are edited as text: new elements are inserted before the closing root
14
+ tag and a ``w15:done`` flag is rewritten in place, so everything else, namespace
15
+ declarations under ``mc:Ignorable`` included, stays byte for byte.
16
+
17
+ Ids
18
+ A comment's ``w:id`` is chosen by the caller (above every id in the story parts).
19
+ ``paraId`` and ``durableId`` are 8 hex digits below ``0x80000000``, derived from the
20
+ package hash and a counter, so the same document and request always give the same
21
+ ids; they are unique among every ``paraId`` of every part and every durable id.
22
+
23
+ Replies
24
+ ``paraIdParent`` always names the thread's root comment, as Word writes it. A thread
25
+ is resolved by setting ``done`` on the root and on each reply.
26
+
27
+ A package that has ``comments.xml`` but lacks one of the other parts gets it, filled for
28
+ the comments that carry a ``paraId`` (``commentsExtensible`` copies the durable ids of
29
+ ``commentsIds`` when that exists). A comment without a ``paraId`` that a reply or a
30
+ resolve names gets one added to its last paragraph.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import posixpath
37
+ import re
38
+ import xml.etree.ElementTree as ET
39
+ from dataclasses import dataclass
40
+ from typing import Final
41
+ from xml.sax.saxutils import escape, quoteattr
42
+
43
+ from doc_control.errors import DocControlError, ErrorCode
44
+ from doc_control.model.parts import (
45
+ DOCUMENT_RELS,
46
+ MS_REL_2011,
47
+ OFFICE_REL,
48
+ W14,
49
+ W15,
50
+ W,
51
+ related_part,
52
+ )
53
+ from doc_control.ooxml.zipio import Package
54
+
55
+ NS: Final[dict[str, str]] = {
56
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
57
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
58
+ "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
59
+ "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
60
+ "w15": "http://schemas.microsoft.com/office/word/2012/wordml",
61
+ "wp14": "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
62
+ "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
63
+ "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
64
+ }
65
+ W16CID = f"{{{NS['w16cid']}}}"
66
+ W16CEX = f"{{{NS['w16cex']}}}"
67
+
68
+ COMMENTS = "word/comments.xml"
69
+ EXTENDED = "word/commentsExtended.xml"
70
+ IDS = "word/commentsIds.xml"
71
+ EXTENSIBLE = "word/commentsExtensible.xml"
72
+ PEOPLE = "word/people.xml"
73
+ CONTENT_TYPES = "[Content_Types].xml"
74
+
75
+ _CT = "application/vnd.openxmlformats-officedocument.wordprocessingml."
76
+ _PART_TYPES: Final[dict[str, tuple[str, str]]] = {
77
+ COMMENTS: (_CT + "comments+xml", OFFICE_REL + "comments"),
78
+ EXTENDED: (_CT + "commentsExtended+xml", MS_REL_2011 + "commentsExtended"),
79
+ IDS: (
80
+ _CT + "commentsIds+xml",
81
+ "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
82
+ ),
83
+ EXTENSIBLE: (
84
+ _CT + "commentsExtensible+xml",
85
+ "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
86
+ ),
87
+ PEOPLE: (_CT + "people+xml", MS_REL_2011 + "people"),
88
+ }
89
+ _ROOTS: Final[dict[str, tuple[str, tuple[str, ...]]]] = {
90
+ COMMENTS: ("w:comments", ("w", "r", "mc", "w14", "w15", "wp14")),
91
+ EXTENDED: ("w15:commentsEx", ("w", "r", "mc", "w14", "w15", "wp14")),
92
+ IDS: ("w16cid:commentsIds", ("mc", "w14", "w15", "wp14", "w16cid")),
93
+ EXTENSIBLE: ("w16cex:commentsExtensible", ("mc", "w14", "w15", "wp14", "w16cex")),
94
+ PEOPLE: ("w15:people", ("w", "r", "mc", "w14", "w15", "wp14")),
95
+ }
96
+ _XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
97
+ _ATTRS = r"""(?:"[^"]*"|'[^']*'|[^>"'])*"""
98
+ _HEX8 = re.compile(r"^[0-9A-F]{8}$")
99
+ _PARA_ID = re.compile(r"""\bparaId\s*=\s*["']([0-9A-Fa-f]{8})["']""")
100
+ _DURABLE_ID = re.compile(r"""\bdurableId\s*=\s*["']([0-9A-Fa-f]{8})["']""")
101
+ _REL_ID = re.compile(r"""\bId\s*=\s*["']rId(\d+)["']""")
102
+ _COMMENT_STYLES = ("CommentText", "CommentReference")
103
+ TEXT_ID: Final = "77777777"
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ExistingComment:
108
+ """A comment of ``comments.xml`` with what the other parts say about it."""
109
+
110
+ id: str
111
+ author: str
112
+ date: str
113
+ para_id: str | None # of the last paragraph
114
+ parent_para_id: str | None
115
+ done: bool
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class NewComment:
120
+ id: str
121
+ para_id: str
122
+ durable_id: str
123
+ author: str
124
+ date: str
125
+ parent_id: str | None # the thread's root comment, for a reply
126
+
127
+
128
+ def initials_of(author: str) -> str:
129
+ """Up to three capital letters, one per word of ``author`` (``Claude`` gives ``C``)."""
130
+ letters = [word[0].upper() for word in author.split() if word[0].isalnum()]
131
+ return "".join(letters[:3]) or author[:1].upper()
132
+
133
+
134
+ def _decode(name: str, data: bytes) -> str:
135
+ try:
136
+ return data.decode("utf-8")
137
+ except UnicodeDecodeError as exc:
138
+ raise DocControlError(
139
+ ErrorCode.UNSUPPORTED_CONTENT, f"{name} is not UTF-8 text", part=name
140
+ ) from exc
141
+
142
+
143
+ def _parse(name: str, text: str) -> ET.Element:
144
+ try:
145
+ return ET.fromstring(text.encode("utf-8"))
146
+ except ET.ParseError as exc:
147
+ raise DocControlError(
148
+ ErrorCode.NOT_A_DOCX, f"{name} is not well-formed XML: {exc}", part=name
149
+ ) from exc
150
+
151
+
152
+ def _root_tag(text: str) -> re.Match[str] | None:
153
+ """The start tag of the root element."""
154
+ return re.search(rf"<(?![?!/])([^\s/>]+)({_ATTRS})(/?)>", text)
155
+
156
+
157
+ def _declared(root_tag: str) -> dict[str, str]:
158
+ """``prefix -> uri`` for every ``xmlns:prefix`` on the root start tag."""
159
+ return {
160
+ m.group(1): m.group(2) or m.group(3)
161
+ for m in re.finditer(r"""xmlns:([\w.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""", root_tag)
162
+ }
163
+
164
+
165
+ class _Syntax:
166
+ """Prefixes for writing into an existing part: its own where declared, else local ones."""
167
+
168
+ def __init__(self, text: str) -> None:
169
+ match = _root_tag(text)
170
+ self.bound = _declared(match.group(0)) if match else {}
171
+
172
+ def prefix(self, wanted: str) -> tuple[str, str]:
173
+ """``(prefix, xmlns declaration to add on the element or '')`` for namespace ``wanted``."""
174
+ uri = NS[wanted]
175
+ for prefix, bound in self.bound.items():
176
+ if bound == uri:
177
+ return prefix, ""
178
+ if self.bound.get(wanted, uri) == uri:
179
+ return wanted, f" xmlns:{wanted}={quoteattr(uri)}"
180
+ return wanted + "x", f" xmlns:{wanted}x={quoteattr(uri)}"
181
+
182
+
183
+ def _insert_before_close(name: str, text: str, fragment: str) -> str:
184
+ """``text`` with ``fragment`` as the last child of its root element."""
185
+ match = _root_tag(text)
186
+ if match is None:
187
+ raise DocControlError(ErrorCode.NOT_A_DOCX, f"{name} has no root element", part=name)
188
+ if match.group(3): # <root .../>
189
+ start = match.group(0)
190
+ opened = start[:-2].rstrip() + ">"
191
+ return (
192
+ text[: match.start()] + opened + fragment + f"</{match.group(1)}>" + text[match.end() :]
193
+ )
194
+ close = text.rfind("</")
195
+ if close < match.end():
196
+ raise DocControlError(ErrorCode.NOT_A_DOCX, f"{name} has no closing tag", part=name)
197
+ return text[:close] + fragment + text[close:]
198
+
199
+
200
+ class CommentParts:
201
+ """Read the comment parts of ``pkg`` and plan additions to all of them."""
202
+
203
+ def __init__(self, pkg: Package, *, styles: frozenset[str] = frozenset()) -> None:
204
+ self.pkg = pkg
205
+ self._styles = styles
206
+ self._seed = pkg.sha256
207
+ self._counter = 0
208
+ self.names: dict[str, str | None] = {}
209
+ self._text: dict[str, str] = {}
210
+ for default, (_, rel) in _PART_TYPES.items():
211
+ name = related_part(pkg, rel, default)
212
+ self.names[default] = name
213
+ if name is not None:
214
+ self._text[default] = _decode(name, pkg.read(name))
215
+ self._new_text: dict[str, str] = {} # additions: part -> text; set after the first edit
216
+ self._used_para = self._scan_ids(_PARA_ID)
217
+ self._used_durable = self._scan_ids(_DURABLE_ID)
218
+ self.existing = self._load()
219
+ self.added: list[NewComment] = []
220
+ self._added_xml: dict[str, str] = {} # comment id -> its w:comment element
221
+ self._done: dict[str, bool] = {} # paraId -> new done flag
222
+ self._fresh: dict[str, str] = {} # comment id -> paraId given to a legacy comment
223
+ self._people: list[str] = []
224
+
225
+ # -- reading ---------------------------------------------------------------
226
+
227
+ def _scan_ids(self, pattern: re.Pattern[str]) -> set[str]:
228
+ used: set[str] = set()
229
+ for name in self.pkg.names:
230
+ if name.startswith("word/") and name.endswith(".xml"):
231
+ used.update(m.upper() for m in pattern.findall(_decode(name, self.pkg.read(name))))
232
+ return used
233
+
234
+ def _load(self) -> dict[str, ExistingComment]:
235
+ text = self._text.get(COMMENTS)
236
+ if text is None:
237
+ return {}
238
+ parent: dict[str, str | None] = {}
239
+ done: dict[str, bool] = {}
240
+ if EXTENDED in self._text:
241
+ for ex in _parse(EXTENDED, self._text[EXTENDED]).iter(W15 + "commentEx"):
242
+ pid = (ex.get(W15 + "paraId") or "").upper()
243
+ if pid:
244
+ value = ex.get(W15 + "paraIdParent")
245
+ parent[pid] = value.upper() if value else None
246
+ done[pid] = (ex.get(W15 + "done") or "0").lower() in ("1", "true", "on")
247
+ out: dict[str, ExistingComment] = {}
248
+ for c in _parse(COMMENTS, text).findall(W + "comment"):
249
+ cid = c.get(W + "id")
250
+ if cid is None:
251
+ continue
252
+ paras = c.findall(W + "p")
253
+ raw = paras[-1].get(W14 + "paraId") if paras else None
254
+ last_pid = raw.upper() if raw else None
255
+ out[cid] = ExistingComment(
256
+ id=cid,
257
+ author=c.get(W + "author") or "",
258
+ date=c.get(W + "date") or "",
259
+ para_id=last_pid,
260
+ parent_para_id=parent.get(last_pid) if last_pid else None,
261
+ done=done.get(last_pid, False) if last_pid else False,
262
+ )
263
+ return out
264
+
265
+ def comment_ids(self) -> set[str]:
266
+ return set(self.existing) | {c.id for c in self.added}
267
+
268
+ def para_id_of(self, cid: str) -> str:
269
+ if cid in self._fresh:
270
+ return self._fresh[cid]
271
+ for c in self.added:
272
+ if c.id == cid:
273
+ return c.para_id
274
+ known = self.existing[cid].para_id
275
+ if known:
276
+ return known
277
+ return self._give_para_id(cid)
278
+
279
+ def root_of(self, cid: str) -> str:
280
+ """The id of the root comment of ``cid``'s thread (``cid`` itself for a root)."""
281
+ seen = {cid}
282
+ current = cid
283
+ while True:
284
+ parent_pid = self._parent_para_id(current)
285
+ if parent_pid is None:
286
+ return current
287
+ nxt = self._comment_with_para_id(parent_pid)
288
+ if nxt is None or nxt in seen:
289
+ return current
290
+ seen.add(nxt)
291
+ current = nxt
292
+
293
+ def _parent_para_id(self, cid: str) -> str | None:
294
+ for c in self.added:
295
+ if c.id == cid:
296
+ return self.para_id_of(c.parent_id) if c.parent_id else None
297
+ return self.existing[cid].parent_para_id
298
+
299
+ def _comment_with_para_id(self, pid: str) -> str | None:
300
+ for new in self.added:
301
+ if new.para_id == pid:
302
+ return new.id
303
+ for cid, old in self.existing.items():
304
+ if old.para_id == pid:
305
+ return cid
306
+ return None
307
+
308
+ def thread(self, root: str) -> list[str]:
309
+ """``root`` and every comment that replies into its thread, in file order."""
310
+ members = [root]
311
+ for cid in [*self.existing, *(c.id for c in self.added)]:
312
+ if cid != root and self.root_of(cid) == root:
313
+ members.append(cid)
314
+ return members
315
+
316
+ # -- ids -------------------------------------------------------------------
317
+
318
+ def _hex_id(self, used: set[str], label: str) -> str:
319
+ n = 0
320
+ while True:
321
+ digest = hashlib.blake2s(f"{self._seed}:{label}:{self._counter}:{n}".encode()).digest()
322
+ value = int.from_bytes(digest[:4], "big") & 0x7FFFFFFF
323
+ candidate = f"{value:08X}"
324
+ if value and candidate not in used:
325
+ used.add(candidate)
326
+ self._counter += 1
327
+ return candidate
328
+ n += 1
329
+
330
+ # -- planning --------------------------------------------------------------
331
+
332
+ def add(
333
+ self, cid: str, text: str, author: str, date: str, parent: str | None = None
334
+ ) -> NewComment:
335
+ """Add comment ``cid``; ``parent`` is the comment replied to (its thread's root is used)."""
336
+ if cid in self.comment_ids():
337
+ raise DocControlError(ErrorCode.INTERNAL, f"comment id {cid} is already taken")
338
+ root = self.root_of(parent) if parent is not None else None
339
+ parent_pid = self.para_id_of(root) if root is not None else None
340
+ lines = text.split("\n")
341
+ para_ids = [self._hex_id(self._used_para, "para") for _ in lines]
342
+ durable = self._hex_id(self._used_durable, "durable")
343
+ self._added_xml[cid] = self._comment_xml(
344
+ cid, lines, para_ids, author, date, initials_of(author)
345
+ )
346
+ comment = NewComment(cid, para_ids[-1], durable, author, date, root)
347
+ self.added.append(comment)
348
+ if author not in self._people:
349
+ self._people.append(author)
350
+ if parent_pid is None:
351
+ return comment
352
+ return comment
353
+
354
+ def set_done(self, cid: str, done: bool) -> bool:
355
+ """Set the resolved flag of ``cid``; True if that changes anything."""
356
+ pid = self.para_id_of(cid)
357
+ current = self._done.get(pid)
358
+ if current is None:
359
+ current = self._existing_done(cid)
360
+ self._done[pid] = done
361
+ return current != done
362
+
363
+ def _existing_done(self, cid: str) -> bool:
364
+ if cid in self.existing:
365
+ return self.existing[cid].done
366
+ return False
367
+
368
+ def _comment_xml(
369
+ self,
370
+ cid: str,
371
+ lines: list[str],
372
+ para_ids: list[str],
373
+ author: str,
374
+ date: str,
375
+ initials: str,
376
+ ) -> str:
377
+ syntax = _Syntax(self._text.get(COMMENTS, ""))
378
+ w, w_decl = syntax.prefix("w")
379
+ w14, w14_decl = syntax.prefix("w14")
380
+ paras = []
381
+ style = (
382
+ f'<{w}:pPr><{w}:pStyle {w}:val="CommentText"/></{w}:pPr>'
383
+ if ("CommentText" in self._styles)
384
+ else ""
385
+ )
386
+ ref_style = (
387
+ f'<{w}:rPr><{w}:rStyle {w}:val="CommentReference"/></{w}:rPr>'
388
+ if "CommentReference" in self._styles
389
+ else ""
390
+ )
391
+ for i, (line, pid) in enumerate(zip(lines, para_ids, strict=True)):
392
+ anchor = f"<{w}:r>{ref_style}<{w}:annotationRef/></{w}:r>" if i == 0 else ""
393
+ paras.append(
394
+ f'<{w}:p {w14}:paraId="{pid}" {w14}:textId="{TEXT_ID}">{style}{anchor}'
395
+ f"{_runs(w, line)}</{w}:p>"
396
+ )
397
+ return (
398
+ f"<{w}:comment{w_decl}{w14_decl} {w}:id={quoteattr(cid)} {w}:author={quoteattr(author)}"
399
+ f" {w}:date={quoteattr(date)} {w}:initials={quoteattr(initials)}>"
400
+ + "".join(paras)
401
+ + f"</{w}:comment>"
402
+ )
403
+
404
+ # -- legacy comments without a paraId ----------------------------------------
405
+
406
+ def _give_para_id(self, cid: str) -> str:
407
+ pid = self._hex_id(self._used_para, "legacy")
408
+ self._fresh[cid] = pid
409
+ return pid
410
+
411
+ def _with_legacy_ids(self, text: str) -> str:
412
+ syntax = _Syntax(text)
413
+ w14, decl = syntax.prefix("w14")
414
+ for cid, pid in self._fresh.items():
415
+ start = re.search(
416
+ rf"<(?P<p>[\w.-]+:)?comment(?=[\s>])(?=(?:{_ATTRS})\b[\w.-]+:id=[\"']{re.escape(cid)}[\"'])"
417
+ rf"{_ATTRS}>",
418
+ text,
419
+ )
420
+ if start is None:
421
+ raise DocControlError(ErrorCode.INTERNAL, f"comment {cid} not found in text")
422
+ prefix = start.group("p") or ""
423
+ end = text.find(f"</{prefix}comment>", start.end())
424
+ paras = list(re.finditer(rf"<{re.escape(prefix)}p(?=[\s>/]){_ATTRS}>", text[:end]))
425
+ paras = [m for m in paras if m.start() >= start.end()]
426
+ if not paras:
427
+ raise DocControlError(
428
+ ErrorCode.UNSUPPORTED_CONTENT,
429
+ f"comment {cid} has no paragraph to identify",
430
+ comment=cid,
431
+ )
432
+ tag = paras[-1]
433
+ rewritten = tag.group(0)[:-1].rstrip("/")
434
+ slash = "/" if tag.group(0).endswith("/>") else ""
435
+ rewritten += f'{decl} {w14}:paraId="{pid}"{slash}>'
436
+ text = text[: tag.start()] + rewritten + text[tag.end() :]
437
+ return text
438
+
439
+ # -- building the parts -------------------------------------------------------
440
+
441
+ def build(self) -> dict[str, bytes]:
442
+ """The new bytes of every part that changes or is created, content types and rels too."""
443
+ out: dict[str, str] = {}
444
+ now_fresh = bool(self.added or self._done)
445
+ if not now_fresh:
446
+ return {}
447
+ comments = self._text.get(COMMENTS)
448
+ if comments is None:
449
+ comments = _fresh_root(COMMENTS)
450
+ if self._fresh:
451
+ comments = self._with_legacy_ids(comments)
452
+ if self._added_xml:
453
+ comments = _insert_before_close(COMMENTS, comments, "".join(self._added_xml.values()))
454
+ out[COMMENTS] = comments
455
+ out[EXTENDED] = self._extended()
456
+ out[IDS], out[EXTENSIBLE] = self._ids_and_extensible()
457
+ out[PEOPLE] = self._people_part()
458
+ created = [name for name in out if self.names[name] is None]
459
+ result = {
460
+ (self.names[name] or name): text.encode("utf-8")
461
+ for name, text in out.items()
462
+ if text != self._text.get(name)
463
+ }
464
+ result.update(self._package_parts(created))
465
+ return result
466
+
467
+ def changed_parts(self) -> list[str]:
468
+ return [self.names[n] or n for n in _PART_TYPES]
469
+
470
+ def _existing_with_ids(self) -> list[ExistingComment]:
471
+ return [c for c in self.existing.values() if c.para_id or c.id in self._fresh]
472
+
473
+ def _extended(self) -> str:
474
+ text = self._text.get(EXTENDED)
475
+ entries: list[str] = []
476
+ if text is None:
477
+ text = _fresh_root(EXTENDED)
478
+ for c in self._existing_with_ids():
479
+ pid = c.para_id or self._fresh[c.id]
480
+ entries.append(
481
+ _commentex(_prefixes(text), pid, c.parent_para_id, self._done.get(pid, c.done))
482
+ )
483
+ else:
484
+ known = {m.upper() for m in _PARA_ID.findall(text)}
485
+ for pid, flag in self._done.items():
486
+ if pid in known:
487
+ text = _set_done(text, pid, flag)
488
+ needed = set(self._done) | {
489
+ self.para_id_of(n.parent_id) for n in self.added if n.parent_id
490
+ }
491
+ for c in self._existing_with_ids():
492
+ pid = c.para_id or self._fresh[c.id]
493
+ if (
494
+ pid not in known and pid in needed
495
+ ): # a reply or resolve names a comment the part lacks
496
+ entries.append(
497
+ _commentex(
498
+ _prefixes(text), pid, c.parent_para_id, self._done.get(pid, c.done)
499
+ )
500
+ )
501
+ known.add(pid)
502
+ for pid, flag in self._done.items():
503
+ if pid not in known and pid not in {c.para_id for c in self.added}:
504
+ entries.append(_commentex(_prefixes(text), pid, None, flag))
505
+ known.add(pid)
506
+ syntax = _prefixes(text)
507
+ for new in self.added:
508
+ parent = self.para_id_of(new.parent_id) if new.parent_id else None
509
+ entries.append(
510
+ _commentex(syntax, new.para_id, parent, self._done.get(new.para_id, False))
511
+ )
512
+ return _insert_before_close(EXTENDED, text, "".join(entries)) if entries else text
513
+
514
+ def _ids_and_extensible(self) -> tuple[str, str]:
515
+ ids_text = self._text.get(IDS)
516
+ ex_text = self._text.get(EXTENSIBLE)
517
+ ids_entries: list[str] = []
518
+ ex_entries: list[str] = []
519
+ if ids_text is None:
520
+ ids_text = _fresh_root(IDS)
521
+ if ex_text is None:
522
+ ex_text = _fresh_root(EXTENSIBLE)
523
+ ids_syntax, ex_syntax = _prefixes(ids_text), _prefixes(ex_text)
524
+ durable_of = _durable_map(ids_text)
525
+ have_ids = IDS in self._text
526
+ have_ex = EXTENSIBLE in self._text
527
+ for c in self._existing_with_ids(): # backfill what a freshly created part lacks
528
+ pid = c.para_id or self._fresh[c.id]
529
+ if have_ids and have_ex:
530
+ break
531
+ if not have_ids and not have_ex:
532
+ durable = self._hex_id(self._used_durable, "backfill")
533
+ ids_entries.append(_comment_id(ids_syntax, pid, durable))
534
+ ex_entries.append(_extensible(ex_syntax, durable, c.date))
535
+ elif have_ids and not have_ex and pid.upper() in durable_of:
536
+ ex_entries.append(_extensible(ex_syntax, durable_of[pid.upper()], c.date))
537
+ for new in self.added:
538
+ ids_entries.append(_comment_id(ids_syntax, new.para_id, new.durable_id))
539
+ ex_entries.append(_extensible(ex_syntax, new.durable_id, new.date))
540
+ if ids_entries:
541
+ ids_text = _insert_before_close(IDS, ids_text, "".join(ids_entries))
542
+ if ex_entries:
543
+ ex_text = _insert_before_close(EXTENSIBLE, ex_text, "".join(ex_entries))
544
+ return ids_text, ex_text
545
+
546
+ def _people_part(self) -> str:
547
+ text = self._text.get(PEOPLE) or _fresh_root(PEOPLE)
548
+ syntax = _prefixes(text)
549
+ known = {p.get(W15 + "author") for p in _parse(PEOPLE, text).iter(W15 + "person")}
550
+ entries = []
551
+ for author in self._people:
552
+ if author in known:
553
+ continue
554
+ known.add(author)
555
+ w15 = syntax.prefix("w15")
556
+ entries.append(
557
+ f"<{w15[0]}:person{w15[1]} {w15[0]}:author={quoteattr(author)}>"
558
+ f'<{w15[0]}:presenceInfo {w15[0]}:providerId="None" '
559
+ f"{w15[0]}:userId={quoteattr(author)}/></{w15[0]}:person>"
560
+ )
561
+ return _insert_before_close(PEOPLE, text, "".join(entries)) if entries else text
562
+
563
+ def _package_parts(self, created: list[str]) -> dict[str, bytes]:
564
+ """Content-type overrides and relationships for the parts being created."""
565
+ if not created:
566
+ return {}
567
+ types = _decode(CONTENT_TYPES, self.pkg.read(CONTENT_TYPES))
568
+ try:
569
+ declared = {
570
+ (o.get("PartName") or "").lstrip("/").lower()
571
+ for o in ET.fromstring(types.encode("utf-8"))
572
+ if o.tag.endswith("Override")
573
+ }
574
+ except ET.ParseError as exc:
575
+ raise DocControlError(
576
+ ErrorCode.NOT_A_DOCX, f"{CONTENT_TYPES} is not well-formed XML: {exc}"
577
+ ) from exc
578
+ rels_text = (
579
+ _decode(DOCUMENT_RELS, self.pkg.read(DOCUMENT_RELS))
580
+ if DOCUMENT_RELS in self.pkg
581
+ else (
582
+ _XML_DECL
583
+ + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/'
584
+ + 'relationships"></Relationships>'
585
+ )
586
+ )
587
+ next_rid = max((int(m) for m in _REL_ID.findall(rels_text)), default=0) + 1
588
+ overrides = []
589
+ rels = []
590
+ for name in created:
591
+ if name.lower() not in declared:
592
+ overrides.append(
593
+ f'<Override PartName="/{name}" ContentType="{_PART_TYPES[name][0]}"/>'
594
+ )
595
+ target = posixpath.relpath(name, "word")
596
+ rels.append(
597
+ f'<Relationship Id="rId{next_rid}" Type="{_PART_TYPES[name][1]}" '
598
+ f"Target={quoteattr(target)}/>"
599
+ )
600
+ next_rid += 1
601
+ out: dict[str, bytes] = {}
602
+ if overrides:
603
+ out[CONTENT_TYPES] = _insert_before_close(
604
+ CONTENT_TYPES, types, "".join(overrides)
605
+ ).encode("utf-8")
606
+ out[DOCUMENT_RELS] = _insert_before_close(DOCUMENT_RELS, rels_text, "".join(rels)).encode(
607
+ "utf-8"
608
+ )
609
+ return out
610
+
611
+
612
+ # -- text helpers ----------------------------------------------------------------------
613
+
614
+
615
+ def _runs(w: str, line: str) -> str:
616
+ out = []
617
+ for part in re.split(r"(\t)", line):
618
+ if part == "\t":
619
+ out.append(f"<{w}:r><{w}:tab/></{w}:r>")
620
+ elif part:
621
+ out.append(f'<{w}:r><{w}:t xml:space="preserve">{escape(part)}</{w}:t></{w}:r>')
622
+ return "".join(out)
623
+
624
+
625
+ def _fresh_root(part: str) -> str:
626
+ tag, prefixes = _ROOTS[part]
627
+ decls = "".join(f" xmlns:{p}={quoteattr(NS[p])}" for p in prefixes)
628
+ ignorable = ' mc:Ignorable="w14 w15 wp14"'
629
+ return f"{_XML_DECL}<{tag}{decls}{ignorable}></{tag.split(':')[0]}:{tag.split(':')[1]}>"
630
+
631
+
632
+ def _prefixes(text: str) -> _Syntax:
633
+ return _Syntax(text)
634
+
635
+
636
+ def _commentex(syntax: _Syntax, pid: str, parent: str | None, done: bool) -> str:
637
+ p, decl = syntax.prefix("w15")
638
+ link = f' {p}:paraIdParent="{parent}"' if parent else ""
639
+ return f'<{p}:commentEx{decl} {p}:paraId="{pid}"{link} {p}:done="{int(done)}"/>'
640
+
641
+
642
+ def _comment_id(syntax: _Syntax, pid: str, durable: str) -> str:
643
+ p, decl = syntax.prefix("w16cid")
644
+ return f'<{p}:commentId{decl} {p}:paraId="{pid}" {p}:durableId="{durable}"/>'
645
+
646
+
647
+ def _extensible(syntax: _Syntax, durable: str, date: str) -> str:
648
+ p, decl = syntax.prefix("w16cex")
649
+ when = f" {p}:dateUtc={quoteattr(date)}" if date else ""
650
+ return f'<{p}:commentExtensible{decl} {p}:durableId="{durable}"{when}/>'
651
+
652
+
653
+ def _durable_map(ids_text: str) -> dict[str, str]:
654
+ if not ids_text or "commentId" not in ids_text:
655
+ return {}
656
+ root = ET.fromstring(ids_text.encode("utf-8"))
657
+ return {
658
+ (e.get(W16CID + "paraId") or "").upper(): (e.get(W16CID + "durableId") or "").upper()
659
+ for e in root.iter(W16CID + "commentId")
660
+ }
661
+
662
+
663
+ def _set_done(text: str, pid: str, done: bool) -> str:
664
+ """Rewrite ``w15:done`` on the ``commentEx`` element whose ``paraId`` is ``pid``."""
665
+ for m in re.finditer(rf"<(?:[\w.-]+:)?commentEx(?=[\s/>]){_ATTRS}>", text):
666
+ tag = m.group(0)
667
+ found = _PARA_ID.search(tag)
668
+ if found is None or found.group(1).upper() != pid:
669
+ continue
670
+ prefix = re.search(r"([\w.-]+):paraId", tag)
671
+ name = f"{prefix.group(1)}:done" if prefix else "done"
672
+ value = f'{name}="{int(done)}"'
673
+ if re.search(rf"""\b{re.escape(name)}\s*=\s*(?:"[^"]*"|'[^']*')""", tag):
674
+ new = re.sub(rf"""\b{re.escape(name)}\s*=\s*(?:"[^"]*"|'[^']*')""", value, tag, count=1)
675
+ else:
676
+ closing = "/>" if tag.endswith("/>") else ">"
677
+ new = tag[: -len(closing)].rstrip() + f" {value}{closing}"
678
+ return text[: m.start()] + new + text[m.end() :]
679
+ return text