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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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
|